diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a985f9..cc67b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,78 @@ History before 0.1.0 is not reconstructed here; see the change log in ## [Unreleased] +## [0.1.0-beta.2] - 2026-11-04 (pre-release) + +Fixes for the first beta reports (issues #3 to #7) and a few problems found while fixing them, +and behavior of the C# version (0.0.8 and earlier) that the rewrite had lost. +Windows build: NSIS installer only, as for beta.1. + +### Added + +- **Theme** setting (Dark / Light / System) in the Settings panel; all windows follow it. Before, + the app was dark-only while native widgets followed the OS theme ([#6]). +- Save and Copy include the timestamps when Timestamp is ON in the ASCII view: every line starts + with its time (`HH:MM:SS.d`, the same as on screen) and the chosen separator. With Timestamp + OFF, Save writes the received bytes unchanged as before ([#4]). +- Plotter: names and values joined by `=`, `:` or a space are recognized, separated by commas, + tabs or spaces (`V=30.58 I=0.82`, `temp = 25, hum = 60`, `temp 25.5 hum 60`). A number followed + by a short unit (`11ms`, `3.3V`) is read as the number. Values that are neither a number nor a + word (`0.825/0.797`) and empty values (`key=`) no longer create channels. +- **Show CR/LF** and **Show NUL** in the ASCII view, both off by default (C#: Show [CRLF] / + Show [NUL]). Off hides CR and LF, shows TAB as a real tab and hides NUL; copying a selection + still keeps the line breaks. Before, every line ended in visible CR LF symbols. +- Timestamps in the Hex view: each 16-byte row shows the time of its first byte, and Copy + includes them. Save in the Hex view still writes the received bytes. +- "Copied" / "Saved" / "Nothing to copy" confirmation that disappears by itself. + +### Changed + +- Settings are remembered across restarts: serial settings, the last chosen port, Send and Receive + options, the plotter's window width and downsampling mode, and the theme. The send history and + the window size/position are not stored ([#7]). +- Opening the port dropdown re-enumerates the ports first, so a device plugged in a moment ago + is listed ([#5]). +- The port list fills the width left by the controls on its right, which are right-aligned; its + width no longer depends on the device names. The Auto Connect status moved to the MCP Server + row. +- The window title bar follows the chosen theme. +- **Auto Reconnect is now Auto Connect**, as in the C# version: while it is on and no port is + open, the app connects to the selected port as soon as it can, also at startup and when the + device is plugged in for the first time. Disconnect (or Cancel while waiting) turns it off. + When the port is listed but cannot be opened (in use by another program), it says so and + retries every second. The setting starts off; an earlier Auto Reconnect setting is not + carried over. The retry interval is 200 ms (was 500 ms). +- Defaults for new installs (saved settings are kept): **DTR and RTS off** (were on), so that + connecting resets neither an Arduino Uno (DTR) nor an ESP32 (RTS). Boards with native USB may + need DtrEnable ticked; a **?** next to DtrEnable / RtsEnable explains this on hover. The other + defaults match the C# version: no line ending (was LF), Send with Enter on (was off), Line + Wrap off (was on), timestamp separator `>` (new option). +- The Send box uses a larger font than the receive view, as in the C# version. +- Port names no longer repeat the port: `USB Serial Port (COM5)` instead of + `USB Serial Port (COM5) (COM5)`. + +### Fixed + +- Auto Scroll stopped short of the newest line when Line Wrap made lines span several rows, and + for very large captures ([#3]). +- Reopening the plotter window could show "Peak" while the backend still used "Average". +- The Hex view could not scroll to the newest rows of very large captures (more than about 8 MB). +- A plotter line with `name=value` pairs was taken as the CSV header when it came first. +- Opening the plotter after data had arrived drew all of that data as a single point at time 0; + it is now drawn at the times it was received. +- A lone CR now ends a line, as in the C# version; devices that end lines with CR only were + shown as one long line. +- Hex send accepts the C# separators and one-digit bytes (`0A-0B`, `1,2,3`, `0x41`). Before, + `0A-0B` was rejected and `12 3 4` was silently sent as `12 34`. An invalid byte is now named + under the input instead of in a blocking dialog. +- Input that is only line breaks is no longer added to the send history. + +[#3]: https://github.com/771-8bit/SerialMonitorEssential/issues/3 +[#4]: https://github.com/771-8bit/SerialMonitorEssential/issues/4 +[#5]: https://github.com/771-8bit/SerialMonitorEssential/issues/5 +[#6]: https://github.com/771-8bit/SerialMonitorEssential/issues/6 +[#7]: https://github.com/771-8bit/SerialMonitorEssential/issues/7 + ## [0.1.0-beta.1] - 2026-11-04 (pre-release) First beta of the Tauri/Rust version, released to validate it in real use. It contains diff --git a/README.md b/README.md index 86ccf35..960258f 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,23 @@ did all of that, so I built this one. It is a Tauri/Rust rewrite of an earlier C - Lossless receive at high line rates: data is kept in fixed-size chunks and spilled to disk, so long captures do not grow memory without bound -- Hex / ASCII views with virtual scrolling, timestamps, line wrap and visible control characters +- Hex / ASCII views with virtual scrolling, timestamps (in both views), line wrap, and CR / LF / + NUL shown as symbols when you want to see them. CR, LF and CRLF all end a line - Regex search and filter over the whole capture -- Send text or hex, with a selectable line ending (none / CR / LF / CRLF) and input history +- Send text or hex (`48 65 6C`, `0A-0B`, `1,2,3`), with a selectable line ending + (none / CR / LF / CRLF) and input history (↑ / ↓) - Any baud rate the driver accepts (including non-standard ones), DTR / RTS control, hardware or software flow control -- **Auto Reconnect**: when the board resets or is unplugged and comes back on the same port, the - app reopens it and keeps adding to the same capture, so the output from before the reset is - not lost -- Save the capture as a binary file, or copy it as hex or ASCII +- **Auto Connect**: connects to the selected port whenever it is available — at startup, when + the board is plugged in, and when it comes back after a reset or unplug. The capture + continues, so the output from before the reset is not lost. Disconnect turns it off +- Save the capture as a binary file or as text with timestamps, or copy it as hex or ASCII + +DTR and RTS are off by default, so connecting does not reset the board (an Arduino Uno resets +when DTR turns on, as with the Arduino IDE; an ESP32 is held in reset by RTS without DTR). +Boards with native USB (Leonardo, Raspberry Pi Pico, ESP32-S3 USB) may send nothing until DTR +is on: tick **DtrEnable** for those. Hover the **?** next to the checkboxes for details. The +other defaults match the earlier C# version: no line ending, Send with Enter on. ### Plotter @@ -188,15 +196,45 @@ tests run in CI, plus a smoke test under WSL2. Wayland and HiDPI have not been t ## Building from source -Prerequisites: Node.js 20+, stable Rust, and the -[Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS. +### 1. Prerequisites + +- [Node.js](https://nodejs.org/) 20 or later +- Stable Rust via [rustup](https://rustup.rs/) (`rustup update stable` if you already have it) +- The [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS: + - **Windows:** Microsoft C++ Build Tools with the "Desktop development with C++" workload. + WebView2 is already part of Windows 10 (recent updates) and 11. + - **Ubuntu 22.04+:** + + ```sh + sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ + librsvg2-dev libxdo-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libudev-dev \ + build-essential pkg-config libssl-dev + sudo usermod -aG dialout $USER # serial port access; log in again afterwards + ``` + +### 2. Run from source ```sh +git clone https://github.com/771-8bit/SerialMonitorEssential.git +cd SerialMonitorEssential npm install -npm run tauri dev # run in development mode (set RUST_LOG=debug for verbose logs) -npm run tauri build # build installers into src-tauri/target/release/bundle +npm run tauri dev +``` + +`npm run tauri dev` starts the frontend dev server and builds and launches the app. The first run +compiles all Rust dependencies and takes a few minutes; later runs are incremental. Changes to the +frontend (`src/`) reload in place, changes to the backend (`src-tauri/`) rebuild and restart the app +(add `-- --no-watch` to turn that off). Set `RUST_LOG=debug` for verbose backend logs. -# the same checks CI runs +### 3. Build an installer + +```sh +npm run tauri build # installers in src-tauri/target/release/bundle (nsis/, msi/, deb/, appimage/) +``` + +### 4. Checks (the same as CI) + +```sh npm run type-check && npm run lint && npm run format:check && npm test cd src-tauri && cargo fmt -- --check && cargo clippy --all-targets -- -D warnings && cargo test ``` diff --git a/docs/07_plotter_spec.md b/docs/07_plotter_spec.md index c79ec3d..bd7f4e9 100644 --- a/docs/07_plotter_spec.md +++ b/docs/07_plotter_spec.md @@ -39,7 +39,7 @@ Arduino IDE互換の形式を基本とし、拡張形式もサポートする。 |------|------|-----| | **単一値** | 1行に1つの数値 + 改行 | `123.45\r\n` | | **複数値 (CSV)** | カンマ/タブ/スペース区切り | `10,20,30\r\n` または `10\t20\t30\r\n` | -| **ラベル付き値** | `label:value` 形式 | `temp:25.5,humidity:60\r\n` | +| **ラベル付き値** | `label:value` / `label=value` / `label value` 形式 | `temp:25.5,humidity:60\r\n`、`V=30.58 I=0.82\r\n`、`temp 25.5 hum 60\r\n` | | **ヘッダー行** | 列名定義(初回のみ) | `temp,humidity,pressure\r\n` | | **状態値** | 文字列/数値で状態を表現 | `state:RUNNING\r\n` または `motor:1\r\n` | @@ -79,12 +79,19 @@ pub enum ChannelValue { 1. **行区切り:** 改行コードで1データポイントを区切る 2. **値区切り:** カンマ `,` > タブ `\t` > スペース ` ` の優先順位で検出 -3. **ラベル検出:** コロン `:` でラベルと値を分離 +3. **ラベル検出(2026-11-04 拡張、SYS-F-715):** + - 最初の `=` または `:` でラベルと値を分離する。前後の空白は可(`temp = 25`、`temp: 25`) + - ラベルは文字(日本語可)か `_` で始まること。`12:34:56` のような時刻はペアにしない + - スペース区切りの行では「単語 数値」もペアにする(`temp 25.5 hum 60`)。ただしヘッダー採用後は使わず、行内の裸の数値がすべて直前に単語を持つときだけ(`RUNNING 25.5 60` は従来どおり列位置) + - ペアを含むスペース区切りの行では、ペアにならない単語(`[Mon`、`occurred`)は捨てる。カンマ/タブ区切りでは列位置の状態値として残す + - 値が空のペア(`key=`)は捨てる(列位置の状態値にしない) 4. **型推論:** - - 数値としてパース可能 → `ChannelValue::Numeric` - - パース不可 → `ChannelValue::State` + - 有限の数値 → `ChannelValue::Numeric` + - 数値+短い単位(4 文字以下・数字なし: `11ms`、`3.3V`、`100%`、`2916094]`)→ 単位を除いて `Numeric` + - 文字か `_` で始まる値(`RUNNING`、`MODE2`、`NaN`)→ `ChannelValue::State` + - それ以外(`0.825/0.797`、`1/1`、`0x1F`、`-inf`、`=` や `:` を含む値)→ 捨てる。状態にすると毎行新しい状態になり State Timeline が埋まるため 5. **ラベル自動生成:** ラベルがない場合は `ch0`, `ch1`, ... を自動付与 -6. **ヘッダー検出:** 最初の行がすべて非数値の場合、ヘッダーとして解釈 +6. **ヘッダー検出:** 最初の行がすべて非数値で、`:` も `=` も含まない場合、ヘッダーとして解釈 ### エラーハンドリング diff --git a/docs/21_system_requirements.md b/docs/21_system_requirements.md index 0475d5f..7371376 100644 --- a/docs/21_system_requirements.md +++ b/docs/21_system_requirements.md @@ -68,7 +68,7 @@ ISO/IEC/IEEE 29148 の要求記述スタイル(一意な ID、shall 形式、 | **SYS-F-205** | システムは、ディスク書き込みに失敗した場合でも該当データを失わず、再試行の対象として保持し続けること。またディスクフル等のエラーを利用者に通知すること。通知は同種のエラーが連続しても 5 秒に 1 回までに抑えること | 書き込み失敗でデータが消えるのは SYS-F-201 違反。ディスクフルは毎周期発生するため、抑制しないと通知が画面を埋める | UN-10 | T, I | 実装済(*2) | | **SYS-F-206** | システムは、受信バイト総数の変化をフロントエンドへ通知すること。通知頻度は約 60 通知/秒(16 ms 間隔)を上限に間引くこと | 12 Mbps では 1 バイトごとの通知は不可能。UI 側の更新契機だけが必要 | UN-15 | I, D | 実装済 | | **SYS-F-207** | システムは、受信データに対して約 100 ms 間隔で受信時刻を記録し、任意のバイトオフセットに対応する時刻を取得できること | ASCII ビューアのタイムスタンプ列。全バイトに時刻を持たせるのは容量的に不可 | UN-07 | T | 実装済 | -| **SYS-F-208** | システムは、受信データ中の改行 (LF) 位置を受信時に索引化し、行番号からバイト範囲を取得できること | 行指向の ASCII 表示で、表示のたびに全走査しないため | UN-07 | T | 実装済 | +| **SYS-F-208** | システムは、受信データ中の改行位置を受信時に索引化し、行番号からバイト範囲を取得できること。改行は LF・CR・CRLF のいずれも 1 つの改行とし(CRLF は 1 つ、LF CR と CR CR は 2 つ)、チャンク境界をまたぐ CRLF も 1 つと数えること | 行指向の ASCII 表示で、表示のたびに全走査しないため。CR だけで改行する機器の出力が 1 行に繋がらないため(C# 版は CR 単独も改行として表示していた。2026-11-05 デグレとして修正) | UN-07 | T(`test_record_line_offsets_cr_rules` ほか) | 実装済(CR は 2026-11-05) | | **SYS-F-209** | システムは、一時ファイルをシステムの一時ディレクトリ配下に置き、**インスタンスごとに一意な**サブディレクトリを用いること。アプリ終了時に自分の領域を削除し、起動時に使用されていない過去の領域を削除すること | 利用者のディスクを汚さない。かつ、同一プロセス内の旧インスタンスの後始末が新インスタンスのライブデータを消さないため(2026-09-03 修正) | UN-14, UN-17, UN-21 | I, D | 実装済 | | **SYS-F-210** | システムは、受信済みの全データをバイナリのままファイルへエクスポートできること。エクスポート中もメモリ使用量が受信データ量に比例して増加しないこと | 外部ツールでの解析。GB 級のログを一度にメモリへ載せない | UN-13, UN-16 | I, D | 実装済 | | **SYS-F-211** | システムは、受信済みデータを Hex 文字列または ASCII 文字列としてクリップボードへ渡せること。大量データの場合は事前に利用者へ確認すること | 貼り付けて共有する運用。無警告のフリーズを避ける | UN-07 | I, D | 実装済 | @@ -82,12 +82,12 @@ ISO/IEC/IEEE 29148 の要求記述スタイル(一意な ID、shall 形式、 | ID | 要求 | 根拠 | 上流 | 検証 | 状態 | |----|------|------|------|------|------| | **SYS-F-301** | システムは、受信データを 16 バイト/行の Hex ダンプ(オフセット列・Hex 列・ASCII 列)として表示できること | バイナリプロトコルのデバッグの基本形式 | UN-07 | T, D | 実装済 | -| **SYS-F-302** | システムは、受信データを行区切りの ASCII テキストとして表示できること。制御文字 (0x00–0x1F, 0x7F) は Unicode Control Pictures で、非 ASCII (0x80–0xFF) は中点で可視化すること | 「見えない文字」がバグの原因であることが多い。空白で潰すと原因が見えない | UN-07 | T | 実装済 | +| **SYS-F-302** | システムは、受信データを行区切りの ASCII テキストとして表示できること。制御文字 (0x00–0x1F, 0x7F) は Unicode Control Pictures で、非 ASCII (0x80–0xFF) は中点で可視化すること。ただし **Show CR/LF**(既定 OFF)が OFF のときは CR・LF を表示せず TAB を実際のタブとして、**Show NUL**(既定 OFF)が OFF のときは NUL を表示しないこと。非表示の CR・LF も選択範囲のコピーでは改行として残すこと | 「見えない文字」がバグの原因であることが多い。空白で潰すと原因が見えない。一方、普段の流し見で全行の末尾に CR LF の記号が並ぶのは邪魔で、C# 版は表示を切り替えられた(Show [CRLF] / Show [NUL]、既定 OFF。2026-11-05 デグレとして復元) | UN-07 | T, D | 実装済(切り替えは 2026-11-05) | | **SYS-F-303** | システムは、Hex ⇔ ASCII を切り替えたとき、**表示中のバイトオフセットを維持すること** | 同じバイトを 2 つの視点で見るのが目的 | UN-08 | T, D | 実装済 | | **SYS-F-304** | システムは、フロントエンドが保持する行データを画面に見える範囲+前後バッファに限定すること。全行分の配列をフロントエンドで生成しないこと | 32 MB = 約 200 万行。全行生成は OOM を招く([03_data_structures.md](03_data_structures.md)) | UN-15, UN-16 | I, A | 実装済 | | **SYS-F-305** | システムは、ブラウザの DOM 高さ上限(約 33M px)を超えるデータ量でもスクロールできること。上限を超える場合はスクロール量をスケーリングすること | GB 級のログでスクロールバーが機能しなくなるのを防ぐ | UN-07 | T | 実装済 | | **SYS-F-306** | システムは、Auto Scroll が ON のとき新規データ到着に追従して最下端を維持し、OFF のとき表示バイト位置を維持すること | 流し見と精読の切り替え | UN-07 | T, D | 実装済 | -| **SYS-F-307** | システムは、ASCII モードで行ごとの受信時刻表示、および行折り返しを切り替えられること。切り替えは**次のデータ到着やスクロールを待たずに即座に表示へ反映すること** | 2026-09-03 修正前は反映が遅延し、切り替えたのに変わらないように見えた | UN-07, UN-17 | D | 実装済 | +| **SYS-F-307** | システムは、ASCII モードで行ごとの受信時刻表示、および行折り返しを、Hex モードで 16 バイト行ごとの受信時刻(行の先頭バイトの時刻)表示を切り替えられること。Hex モードで受信時刻が ON のとき、Copy は各行に時刻を付けること(Save は常に受信バイトのまま)。切り替えは**次のデータ到着やスクロールを待たずに即座に表示へ反映すること** | 2026-09-03 修正前は反映が遅延し、切り替えたのに変わらないように見えた | UN-07, UN-17 | D | 実装済 | | **SYS-F-308** | システムは、表示データ取得が処理中の間に到着した再取得要求を破棄せず、処理完了後に最新の要求で再取得すること | 破棄すると「最後のデータだけ表示されない」が起きる(2026-09-03 修正) | UN-17 | I, D | 実装済 | | **SYS-F-309** | システムは、受信データに対する検索およびフィルタ表示を提供すること | 数十万行から目的の行を見つける手段 | UN-07 | — | **計画**(UI のみ存在。GAP-01) | @@ -171,6 +171,7 @@ ISO/IEC/IEEE 29148 の要求記述スタイル(一意な ID、shall 形式、 | **SYS-F-711** | システムは、改行を含まないストリームを受信し続けた場合でも、内部バッファを無制限に成長させないこと(64 KB を上限とする) | バイナリストリームをプロッタが解釈しようとした場合の防御 | UN-16 | I | 実装済 | | **SYS-F-712** | システムは、1 チャンネルあたり**メモリ上**に保持する状態変化記録数に上限(10,000 件)を設けること。超過した古い変化は**ディスクへ書き出し**(SYS-F-518 と同じ保存)、静的範囲でメモリより古い時刻を要求されたら読み戻して返すこと(1 回の要求で最大 50,000 件) | 状態が高速に変化するとメモリを圧迫する。一方で、まれな異常状態を後から遡って見られないと見落としになる(2026-11-04、ディスクへの書き出しを追加。それまでは破棄していた) | UN-16, UN-06 | T(`test_state_changes_beyond_memory_cap_come_back_from_spill`, `spill::tests::test_state_segments_round_trip`) | 実装済 | | **SYS-F-713** | 受信の開始・再開位置(接続時のオフセット 0、自動再接続の再開位置)から始まる最初の行は、行の途中から始まっているかもしれないので、**データ点にしないこと**(CSV のヘッダー行の検出にだけ使う)。再開位置より前の途中の行も捨て、再開後の行とつなげないこと。CR / LF / CRLF のどの改行でも働くこと。AI 向けの索引(insight)も再開位置で途中の行を捨てること | 動いているデバイスにつなぐと最初の行はほぼ必ず途中から始まり、`sin:39.05,cos:` と次の行がつながって、消えない偽のチャンネル(`cos` が状態 `accel:0.049` になる等)を作っていた(docs/27 PF-2、2026-11-04 の画面評価で 2 回観測)。一方で DTR でリセットして起動する基板はヘッダー行から送るので、最初の行を単純に捨てるとヘッダーを毎回失う | UN-01, UN-06 | T(パーサ 7 件 `test_segment_start_*`、スレッド 3 件 `test_cut_first_line_of_a_new_store_is_dropped` ほか、insight 1 件) | 実装済(2026-11-04) | +| **SYS-F-715** | システムは、名前と値の結合子として `:` / `=` / スペースを、ペアの区切りとしてカンマ / タブ / スペースを受け付けること(前後の空白可)。名前は文字か `_` で始まること。数値に続く短い単位(`11ms`、`3.3V`)は除いて数値として扱うこと。数値でも文字始まりでもない値(`0.825/0.797`、`1/1`)と値が空のペアはチャンネルにしないこと。`=` を含む行をヘッダーにしないこと。スペースを結合子とするのは、スペース区切りでヘッダーがなく、行内の裸の数値がすべて直前に単語を持つときだけとすること | 実機ファームウェアの `[Mon t=2916094] V=30.58 Vdc=12.37 I=0.825/0.797 … age=11ms` が、すべて列位置の状態値になり、約 20 本の状態行だけが並んで数値グラフが 1 本も出なかった(オーナー要求 2026-11-04、Fable レビュー済み)。値の捨て方は State Timeline の上限(SYS-F-712)を無意味な状態で埋めないため | UN-01, UN-17 | T(パーサ 12 件 `test_equals_pairs_from_real_firmware` ほか、`prop_named_pairs_roundtrip`) | 実装済(2026-11-04) | | **SYS-F-714** | State Timeline の色は**意味を持つ**こと: 異常(FAULT / ERR / ALARM / FAIL / TRIP など)は赤・橙系、警告は琥珀、稼働(ON / RUNNING / OK など)は緑、停止・待機(OFF / STOPPED / IDLE など)は灰色。それ以外の状態名は、これらの色を使わないパレットから**登場順に**割り当て、同じセッション内で変えないこと。異なる状態名に同じ色を割り当てないこと(パレットが尽きるまで)。異常の区間は、窓が長くても最低 4 px で描き、隣の区間に塗りつぶされないこと | 旧方式の文字列ハッシュの色相では、`E01` / `E02` / `E03` の色相が 1 度しか違わず、`FAULT_OVERTEMP_LATCHED` が正常の ON と同じ緑になり、正常な STOPPED と ERROR がほぼ同じ赤だった(Fable が計算で確認、docs/27 PF-4) | UN-06 | T(`src/test/stateColors.test.ts` 7 件) | 実装済(2026-11-04)。長い窓での読みやすさ(トグルの表現、ホバー等)は VA-3 で判断 | ## A.8 データ管理(Clear / セッション境界)(8xx) @@ -183,8 +184,8 @@ ISO/IEC/IEEE 29148 の要求記述スタイル(一意な ID、shall 形式、 | **SYS-F-804** | プロッタ解析スレッドは、データストアの差し替えを**1 ポーリング周期(10 ms)以内に検知**し、読み出しオフセットとパーサ状態をリセットすること | 起動時のストア参照を掴み続けると、再接続・Clear 後にプロットが永久停止する(2026-09-03 修正) | UN-12, UN-17 | T(`test_thread_survives_store_swap`) | 実装済 | | **SYS-F-805** | プロッタ解析スレッドは、**データストアが存在しない状態でも動作を継続**し、後から生成されたストアへ接続すること | プロッタウィンドウを先に開いてからポートを接続する操作順を許すため(2026-09-03 修正) | UN-17 | T(`test_aggregator_enabled_empty_data_store`), D | 実装済 | | **SYS-F-806** | ポートが閉じている状態での `Clear` は、データストアを空(不在)にすること。以後の表示要求は空の結果を返し、エラーとしないこと | 切断後にログを消してから再接続する操作を許す | UN-11 | I | 実装済 | -| **SYS-F-807** | **自動再接続**: 利用者が有効にしている場合(既定 OFF、設定は再起動後も保持)、システムは受信中の致命的エラーで切断したデバイスと**同じデバイスパス**(`COM16`、`/dev/ttyACM0`)が一覧に戻ったとき、利用者の操作なしにポートを開き直すこと。このとき (a) **同じデータストアに追記を続け**(オフセットは連続、ブリッジは `reset` を出さない、切断前の受信データは保持)、(b) 切断で途切れた行と再開後の最初の行は**別の行として索引**し(受信バイトは変えない)、(c) 待機中は操作を妨げる通知(モーダル)を出さず、待機中であることを画面に示し、利用者が取り消せること、(d) 待機中はポート選択を自動で変更しないこと、(e) 待機中であることをブリッジの `status.reconnecting` で外部へ示すこと。待機中に変更した通信設定は再接続時に使う。切断中に Clear した場合(ストア不在)は新しいストアで始める | ボードのリセットや書き込みのたびに USB が抜け差しされる。そのたびに手で Connect し直すと、直前のクラッシュ出力(最も見たいログ)が消える。モーダルの alert は JS スレッドを止めるので再接続も止まる。エージェントは `status` を見て「待てば続きが来る」と判断できる | UN-19, UN-10, UN-24 | T, D(実機の抜き差しは未確認 → [27 OV-1〜4](27_open_verification.md)) | 実装済(2026-11-04)。**制約**: 照合はデバイスパスのみ(Linux で `ttyACM0` が `ttyACM1` に変わる再番号付けは追わない)。WebView のタイマは最小化時に間引かれうる | -| **SYS-F-808** | プロッタの時間軸は、**新しいセッション(手動の Connect、Clear)ごとに 0 から始めること**。自動再接続(同じセッション)では戻さないこと。ポートを閉じた状態の Clear では、その時点でプロッタも空にすること。フロントエンドは、データが流れ続けていてもセッションの切り替わり(世代の変化)を検知して表示を作り直し、Inspect 中なら LIVE に戻ること | 以前は時間軸がプロッタを開いた時点から数え続け、再接続後に Duration が実データより長く表示され、300 秒の窓では左側が空いていた(PF-5)。また Disconnect → Clear → Connect では旧セッションのチャンネルが残って新データと混ざっていた(SYS-F-802 違反、PF-7)。原点だけを戻すと、フロントエンドが古い時間軸の窓を要求し続けてプロッタが固まるので、世代番号とセットで入れる(Fable の指摘) | UN-11, UN-12, UN-01 | T(`test_store_swap_resets_time_origin`, `test_detach_clears_the_plot`, `test_version_increments_on_clear`、フロント 3 件) | 実装済(2026-11-04) | +| **SYS-F-807** | **自動接続(Auto Connect)**: 利用者が有効にしている場合(既定 OFF、設定は再起動後も保持)、システムはポートが開いていない間、**選択中のポート**が開けるようになり次第、利用者の操作なしに開くこと(起動時・デバイスを挿したとき・有効にしたとき。起動時は前回選んだポートを、無くても選択したまま待つ)。受信中の致命的エラーで切断したときは、**同じデバイスパス**(`COM16`、`/dev/ttyACM0`)が一覧に戻ったとき開き直すこと。利用者が Disconnect または待機の Cancel をしたら自動接続を OFF にすること(C# 版と同じ。OFF にしないと即座に開き直す)。一覧にあるのに開けない(他のプログラムが使用中など)ときは再試行を 200 ms から 1 s に落とし、開けない旨を表示すること。自動で開いた場合は常に(a) **同じデータストアに追記を続け**(オフセットは連続、ブリッジは `reset` を出さない、切断前の受信データは保持)、(b) 切断で途切れた行と再開後の最初の行は**別の行として索引**し(受信バイトは変えない)、(c) 待機中は操作を妨げる通知(モーダル)を出さず、待機中であることを画面に示し、利用者が取り消せること、(d) 待機中はポート選択を自動で変更しないこと、(e) 待機中であることをブリッジの `status.reconnecting` で外部へ示すこと。待機中に変更した通信設定は再接続時に使う。切断中に Clear した場合(ストア不在)は新しいストアで始める | ボードのリセットや書き込みのたびに USB が抜け差しされる。そのたびに手で Connect し直すと、直前のクラッシュ出力(最も見たいログ)が消える。モーダルの alert は JS スレッドを止めるので再接続も止まる。エージェントは `status` を見て「待てば続きが来る」と判断できる | UN-19, UN-10, UN-24 | T, D(実機の抜き差しは未確認 → [27 OV-1〜4](27_open_verification.md)) | 実装済(2026-11-04)。**制約**: 照合はデバイスパスのみ(Linux で `ttyACM0` が `ttyACM1` に変わる再番号付けは追わない)。WebView のタイマは最小化時に間引かれうる | +| **SYS-F-808** | プロッタの時間軸は、**新しいセッション(手動の Connect、Clear)ごとに 0 から始めること**。原点はセッションの開始(受信ストアの作成)とし、プロッタを開いた時点にしないこと。プロッタを開く前に届いていたデータは、ストアが約 100 ms ごとに記録する受信時刻の位置に描くこと(1 回の読み出し上限をまたいでも同じ)。自動再接続(同じセッション)では戻さないこと。ポートを閉じた状態の Clear では、その時点でプロッタも空にすること。フロントエンドは、データが流れ続けていてもセッションの切り替わり(世代の変化)を検知して表示を作り直し、Inspect 中なら LIVE に戻ること | 以前は時間軸がプロッタを開いた時点から数え続け、再接続後に Duration が実データより長く表示され、300 秒の窓では左側が空いていた(PF-5)。また Disconnect → Clear → Connect では旧セッションのチャンネルが残って新データと混ざっていた(SYS-F-802 違反、PF-7)。原点だけを戻すと、フロントエンドが古い時間軸の窓を要求し続けてプロッタが固まるので、世代番号とセットで入れる(Fable の指摘)。さらに、データを受信した後でプロッタを開くと、溜まっていた行がすべて開いた瞬間(時刻 0)に割り当てられ、**1 点に潰れて**いた(2026-11-04 の画面確認で発見。同じ原因で `test_batch_timestamps_are_spread` が高負荷時にまれに失敗していた) | UN-11, UN-12, UN-01 | T(`test_store_swap_resets_time_origin`, `test_detach_clears_the_plot`, `test_version_increments_on_clear`, `test_backlog_keeps_its_reception_times`, `test_backlog_spanning_reads_keeps_its_times`, `test_records_off_the_clock_fall_back_to_now`, `test_lines_waiting_at_attach_stay_distinct`、フロント 3 件) | 実装済(2026-11-04) | ## A.9 ウィンドウ・ライフサイクル (9xx) diff --git a/docs/23_traceability.md b/docs/23_traceability.md index cd17d8c..f6fbe06 100644 --- a/docs/23_traceability.md +++ b/docs/23_traceability.md @@ -223,7 +223,7 @@ | SYS-F-804 | 10 ms 以内の世代追従 (INV-1) | `plotter/thread.rs::run`(`Arc::ptr_eq` による識別、10 ms ポーリング) | `UT`: `test_thread_survives_store_swap`, `test_thread_sleeps_between_polls`, `test_thread_reads_data_from_store` | | SYS-F-805 | ストア不在でも動作継続 | `plotter/thread.rs::run`(`None` 分岐で 50 ms 待機) | `UT`: `test_aggregator_enabled_empty_data_store`, `test_parser_is_new_each_thread` / `E2E`(プロッタを先に開いてから接続) | | SYS-F-806 | ポート閉状態の Clear | `serial/mod.rs::clear_data`(`None` にする分岐) | `INSP` / **GAP-14** | -| SYS-F-807 | 自動再接続(同じストアに継続) | `serial/mod.rs::reopen_port` / `set_reconnecting`(`open_port_impl(keep_store=true)`)、`serial/data_store.rs::start_reception`(`total_bytes()` から再開)、`serial/worker_thread.rs`(`start_offset`、`ResumeBoundary`、致命経路の行索引)、`bridge.rs::method_status`(`reconnecting`)、`mcp_stdio.rs` / `mcp/server.mjs`(`connected_label`)、`src/App.tsx`(待機の状態・500 ms の照合・選択の保持)、`src/components/SettingsPanel.tsx`(Auto Reconnect・Cancel・状態表示)、`src/utils/portUtils.ts` | `UT`: `test_resumed_worker_continues_offsets_and_splits_cut_line`, `test_fatal_read_error_indexes_the_last_chunk`, `test_resume_boundary_*`(3 件), `test_status_reports_reconnecting`, `test_tools_call_status_reports_reconnecting` / `FE`: `src/test/App.test.tsx`(`auto reconnect` の 6 件)/ Node smoke `status shows a pending reconnect` / `D`: 実機の抜き差し | +| SYS-F-807 | 自動再接続(同じストアに継続) | `serial/mod.rs::reopen_port` / `set_reconnecting`(`open_port_impl(keep_store=true)`)、`serial/data_store.rs::start_reception`(`total_bytes()` から再開)、`serial/worker_thread.rs`(`start_offset`、`LineIndexer`、致命経路の行索引)、`bridge.rs::method_status`(`reconnecting`)、`mcp_stdio.rs` / `mcp/server.mjs`(`connected_label`)、`src/App.tsx`(自動接続の開始・待機の状態・200 ms の照合と使用中の 1 s 再試行・選択の保持・Disconnect / Cancel で OFF)、`src/components/SettingsPanel.tsx`(Auto Connect・Cancel・状態表示)、`src/utils/portUtils.ts` | `UT`: `test_resumed_worker_continues_offsets_and_splits_cut_line`, `test_fatal_read_error_indexes_the_last_chunk`, `test_resume_boundary_*`(3 件), `test_resume_after_pending_cr`, `test_status_reports_reconnecting`, `test_tools_call_status_reports_reconnecting` / `FE`: `src/test/App.test.tsx`(`auto connect` の 11 件)/ Node smoke `status shows a pending reconnect` / `D`: 実機の抜き差し | | SYS-F-808 | セッションごとに時間軸を 0 から。閉じた状態の Clear でプロッタも空に | `plotter/thread.rs`(アタッチ時に `start_time` を戻す、`had_store`、外れたら `clear`)、`plotter/aggregator.rs`(`generation`)、`src/components/plotter/PlotterWindow.tsx`(世代の変化で作り直し・LIVE へ、`[0, W]` 表示) | `UT`: `test_store_swap_resets_time_origin`, `test_detach_clears_the_plot`, `test_version_increments_on_clear` / `FE`: `re-anchors with a bootstrap request when the session generation changes`, `returns to LIVE when a new session arrives while inspecting`, `does not show negative times before a full window of data exists` | ### 2.9 ウィンドウ・ライフサイクル (SYS-F-9xx) diff --git a/docs/24_vv_plan.md b/docs/24_vv_plan.md index 8cfff43..6e42f0f 100644 --- a/docs/24_vv_plan.md +++ b/docs/24_vv_plan.md @@ -817,7 +817,7 @@ thread.rs を対象に含めた。 | US-07 AI と一緒にデバッグ | Setup Guide だけで MCP 登録できたか。エージェントの要約・待機が実際の調査で役立ったか。AI の送信を画面で追えたか(2026-11-04 追加。表から漏れていた) | **未実施の一覧と記録は [27_open_verification.md §2](27_open_verification.md)**(VA-1〜9。プロッタの VA-1〜3 が最優先)。 -Auto Reconnect(SYS-F-807)は利用者が ON / OFF を選べるので US-02 とは衝突しない(2026-11-04 オーナー判断。US-02 は OFF で確認する)。 +Auto Connect(SYS-F-807)は利用者が ON / OFF を選べるので US-02 とは衝突しない(2026-11-04 オーナー判断。US-02 は OFF で確認する)。 **合否基準**: 上記の問いに「いいえ」が出た場合、その体験を新しいニーズ(UN)として [20_user_needs.md](20_user_needs.md) に追加してから要求へ変換する。実装の場当たり修正で済ませない。 diff --git a/docs/27_open_verification.md b/docs/27_open_verification.md index 774f186..1068ed9 100644 --- a/docs/27_open_verification.md +++ b/docs/27_open_verification.md @@ -49,10 +49,10 @@ | ID | 何が未確認か | 対象 | なぜ未確認か | 確認方法 | 起票 | リリース前 | |----|--------------|------|--------------|----------|------|------------| -| **OV-1** | **自動再接続の実機抜き差し**: USB を抜いて挿し直すと、同じキャプチャに追記して再接続すること(待機中の `status.reconnecting`、`total_bytes` が巻き戻らない、抜く前の行が残る、画面の「Waiting for …」→「Reconnected …」) | SYS-F-807 | 管理者権限なしではデバイスの無効化・再有効化をソフトから起こせず、人の抜き差しが要る。2026-11-04 に試験を仕掛けたが、待ち時間内に抜き差しが行われなかった(その間の送受信 748 行は欠落・順序乱れなし) | FT232H(COM5、D0–D1)でアプリを接続し MCP Server と Auto Reconnect を ON → `python test_tools/ft232h/reconnect_check.py --seconds 300` を実行中にケーブルを抜き、数秒後に挿す。PASS で完了 | 2026-11-04 | **必須**(正式版の前に。β `0.1.0-beta.1` は**未確認のまま公開**する: オーナー判断 2026-11-04) | -| **OV-2** | **実ボードのリセットでの復帰**: USB CDC のボード(Pico 等)がリセットで再列挙したとき、何秒で戻るか。Windows が再列挙直後に open を一時拒否する場合も再試行で戻ること | SYS-F-807 | OV-1 は FTDI(ドライバが常駐する USB-serial)での確認。CDC は再列挙の挙動が違う | Pico を接続し Auto Reconnect ON → リセットボタン(または書き込み)→ 画面の表示と、リセット前後のログが連続していることを確認。復帰までの秒数を記録 | 2026-11-04 | 任意 | +| **OV-1** | **自動再接続の実機抜き差し**: USB を抜いて挿し直すと、同じキャプチャに追記して再接続すること(待機中の `status.reconnecting`、`total_bytes` が巻き戻らない、抜く前の行が残る、画面の「Waiting for …」→「Connected automatically …」) | SYS-F-807 | 管理者権限なしではデバイスの無効化・再有効化をソフトから起こせず、人の抜き差しが要る。2026-11-04 に試験を仕掛けたが、待ち時間内に抜き差しが行われなかった(その間の送受信 748 行は欠落・順序乱れなし) | FT232H(COM5、D0–D1)でアプリを接続し MCP Server と Auto Connect を ON → `python test_tools/ft232h/reconnect_check.py --seconds 300` を実行中にケーブルを抜き、数秒後に挿す。PASS で完了 | 2026-11-04 | **必須**(正式版の前に。β `0.1.0-beta.1` / `0.1.0-beta.2` は**未確認のまま公開**する: オーナー判断 2026-11-04) | +| **OV-2** | **実ボードのリセットでの復帰**: USB CDC のボード(Pico 等)がリセットで再列挙したとき、何秒で戻るか。Windows が再列挙直後に open を一時拒否する場合も再試行で戻ること | SYS-F-807 | OV-1 は FTDI(ドライバが常駐する USB-serial)での確認。CDC は再列挙の挙動が違う | Pico を接続し Auto Connect ON → リセットボタン(または書き込み)→ 画面の表示と、リセット前後のログが連続していることを確認。復帰までの秒数を記録 | 2026-11-04 | 任意 | | **OV-3** | **Linux での自動再接続**: `/dev/ttyACM0` / `/dev/ttyUSB0` が同じ名前で戻る場合に再接続すること | SYS-F-807, SYS-NF-504 | Linux の実機で一度も動かしていない。番号が変わる場合(`ttyACM0` → `ttyACM1`)は追わない仕様(既知の制約) | Ubuntu 22.04 の実機で OV-1 と同じ手順 | 2026-11-04 | 任意 | -| **OV-4** | **最小化中の自動再接続**: メイン窓を最小化・背面にしている間も、デバイスが戻れば数秒以内に再接続すること | SYS-F-807 | 再接続の照合はフロントエンドのタイマ(500 ms)で動く。WebView は非表示時にタイマを間引くことがあり、どこまで遅れるか未測定 | 窓を最小化した状態で OV-1 を行い、復帰までの秒数を記録 | 2026-11-04 | 任意 | +| **OV-4** | **最小化中の自動再接続**: メイン窓を最小化・背面にしている間も、デバイスが戻れば数秒以内に再接続すること | SYS-F-807 | 再接続の照合はフロントエンドのタイマ(200 ms)で動く。WebView は非表示時にタイマを間引くことがあり、どこまで遅れるか未測定 | 窓を最小化した状態で OV-1 を行い、復帰までの秒数を記録 | 2026-11-04 | 任意 | | **OV-5** | **CTS 停止からの送信再開**: 低ボーレートで送信中に D2–D3 ジャンパを一時的に外して戻し、送信が止まってから欠落・重複なく再開すること | ADR-14, SYS-NF-101 | ループバックでは CTS 停止がほぼ起きない。単体試験(偽ポートの `StallAfter`)でのみ固定済み | [24 §6.5](24_vv_plan.md) T2-5c 末尾の手順 | 2026-11-04(24 §6.5 から転記) | 任意 | | **OV-6** | **プロッタの E2E E-15 / E-20**: 連続 2 フレームのピクセル比較による表示の定常性、125% / 150% DPI での State Timeline | SYS-F-504, SYS-NF-304 | E-15 は LIVE が流れ続けるので「右端以外が一致」の比較方法(スクロール量の補正)が未設計。E-20 は表示スケールの変更が要る。E-17 / E-19 は 2026-11-04 に実施(§4) | [24 §6.3](24_vv_plan.md) の各シナリオ | 2026-11-04(24 §6.5 から転記) | 任意 | | **OV-7** | **README の Linux 用 MCP 登録コマンド**: `.deb` で入れたとき `/usr/bin/serial-monitor-essential --mcp` で MCP サーバとして動くこと | SYS-F-1107, README | `.deb` を実機に入れて MCP クライアントから登録したことがない(Windows の `--mcp` は CI と E2E で確認済み) | Ubuntu に `.deb` を入れ、`claude mcp add serial-monitor -- /usr/bin/serial-monitor-essential --mcp` → `serial_status` が返ること | 2026-11-04 | **必須** | @@ -76,8 +76,8 @@ | **VA-1** | **プロッタ: 眺めて判断する**(US-01 / UN-01, 02, 05) | 実センサの波形を数秒眺めて「想定どおりか」(周期・振幅・オフセット)を判断できたか。表示の揺れ・ストリーム停止・今のモードは気になったか。既定のウィンドウ幅(10 s)と既定の間引き(LTTB)は、そのまま使えたか | 揺れないこと(プロパティ試験 P-5。画面での確認 E-15 は §1 OV-6 で未実施)、停止の可視化(E-14)、モード遷移とフッター表示(E-16 / E-18)、ウィンドウ幅の切替(E-13、10 s / 30 s のみ)。いずれも合成波形 | 実機のセンサ(加速度・温度・ADC 等、周期と振幅を自分で知っている信号)を 10 分以上眺める。違和感はその場で書き留める | 2026-11-04 | **必須**(T2-6) | | **VA-2** | **プロッタ: スパイクを追う**(US-03 / UN-03, 04) | 実際に「たまに値が飛ぶ」現象で、飛んだ瞬間を見逃さなかったか。気づいてから Pause → 拡大 → 前後を見る、までの操作が滞りなかったか。LIVE への戻り方に迷わなかったか | バケットが min / max を保持すること(P-3, P-4)、LTTB は静的範囲専用(SYS-F-515)、Y 軸の即時拡大(単体。E2E の E-19 は §1 OV-6 で未実施) | 間欠的に外れ値を出す実機(ノイズの乗る ADC、取りこぼしのある通信など)、無ければファームで低頻度の外れ値を混ぜて、気づけたかを自分で試す | 2026-11-04 | **必須**(T2-6) | | **VA-3** | **プロッタ: State Timeline**(UN-06) | 実際の状態信号(状態数が多い・頻繁に切り替わる・ラベルが長い)でも、数値波形と同じ時間軸で読めたか。数値と状態の対応(「モータ ON の直後に電流が跳ねる」等)を目で追えたか | 描画が重ならないこと(SYS-NF-304。125% / 150% の E2E は §1 OV-6 で未実施)、ラベル付き入力のパース(単体) | 実機のステートマシンの状態(3 状態以上、秒間数回以上の切り替え)を数値と一緒に流す | 2026-11-04 | **必須**(T2-6) | -| **VA-4** | **再書き込みを挟んだ再接続**(US-02 / UN-12) | Auto Reconnect を **OFF** にして、ファーム更新サイクルで切断 → 再接続したとき、セッションが切り替わったことが自明だったか(前のファームの出力と混ざって判断を誤らなかったか) | OFF での新セッション化(SYS-F-803、E2E)。ON での継続は利用者の選択(§4 の決定) | 実際のファーム更新サイクル(ビルド → 書き込み → 動作確認)を Auto Reconnect OFF で数回回す | 2026-11-04 | **必須**(T2-6) | -| **VA-5** | **Auto Reconnect でリセット前のログが役立つか**(UN-19, UN-10) | ボードがクラッシュ → リセットしたとき、残ったリセット直前の出力で原因に近づけたか。「Waiting for …」の表示に気づけたか。既定 OFF は妥当か | §1 OV-1〜4 が通れば挙動は確認済みになる | 実際にクラッシュするファーム(ウォッチドッグ・ハードフォルト)で、リセット前後のログを見て原因を追う | 2026-11-04 | 任意 | +| **VA-4** | **再書き込みを挟んだ再接続**(US-02 / UN-12) | Auto Connect を **OFF** にして、ファーム更新サイクルで切断 → 再接続したとき、セッションが切り替わったことが自明だったか(前のファームの出力と混ざって判断を誤らなかったか) | OFF での新セッション化(SYS-F-803、E2E)。ON での継続は利用者の選択(§4 の決定) | 実際のファーム更新サイクル(ビルド → 書き込み → 動作確認)を Auto Connect OFF で数回回す | 2026-11-04 | **必須**(T2-6) | +| **VA-5** | **Auto Connect でリセット前のログが役立つか**(UN-19, UN-10) | ボードがクラッシュ → リセットしたとき、残ったリセット直前の出力で原因に近づけたか。「Waiting for …」の表示に気づけたか。既定 OFF は妥当か | §1 OV-1〜4 が通れば挙動は確認済みになる | 実際にクラッシュするファーム(ウォッチドッグ・ハードフォルト)で、リセット前後のログを見て原因を追う | 2026-11-04 | 任意 | | **VA-6** | **Hex でバイナリプロトコルを確認**(US-04 / UN-07, 08) | 実際の独自フレームを Hex で読んで、フレーム境界・チェックサムを確かめられたか。Hex / ASCII の往復で位置を見失わなかったか | 表示の正しさと位置の保持(単体・E2E) | 実機のバイナリプロトコル(独自フレーム、Modbus RTU 等) | 2026-11-04 | **必須**(T2-6) | | **VA-7** | **コマンド送信**(US-05 / UN-09) | 実機に対話的にコマンドを打ち続けたとき、履歴の呼び戻しが編集の邪魔をしなかったか。改行コードの選択に迷わなかったか | 送信・履歴・改行コードの動作(単体・E2E) | AT コマンドや独自 CLI を持つ実機で 10 分程度の対話 | 2026-11-04 | **必須**(T2-6) | | **VA-8** | **長時間ログとエクスポート**(US-06 / UN-10, 13) | 数時間の実ログの途中で重くならなかったか。エクスポートしたファイルを、自分の解析スクリプト(Python 等)でそのまま読めたか | 60 分ソークでリーク無し(T2-4)、欠落ゼロ(T2-5c)、エクスポートのバイト一致(単体) | 実機で数時間のログ → エクスポート → 普段の解析手順で読む | 2026-11-04 | **必須**(T2-6) | @@ -103,6 +103,7 @@ | **PF-5** | 所見(軽微) | **手動で再接続すると、プロッタのデータは新しくなるが、時間軸と「Duration」はプロッタを開いた時点から数え続ける。** 新しいデータは 18 秒分なのに「Duration: 2m 29s」と出て、300 秒の窓では左側が空白になった | SYS-F-802 / 803 | 時刻はプロッタのスレッド開始からの相対値 | Duration を「表示中のデータの長さ」にするか、セッションの切り替えで時間の原点を戻すか | **解消(2026-11-04)**: 新しいセッションごとに時間軸を 0 から、Duration はセッション開始からの最後のサンプル時刻、データが窓に満たない間は `[0, W]` で負の時刻を描かない。世代番号で画面に切り替わりを伝える(SYS-F-808、Fable の設計) | | **PF-6** | **欠陥** | **LIVE の窓の右端が実時刻より先へ進み続ける。** 100 Hz で 160 秒後に 3 秒、187 秒後に 3.5 秒、データの右端が窓の右端より手前で止まった。10 秒の窓では約 10 分でデータが画面から消える計算 | US-01 / UN-01、VA-1(10 分以上眺める)に直撃 | 画面側は右端を実時間ぶん進め、取得時は `Math.ceil(右端)` を送る。バックエンドは送られた値を `end_ms` として返し、画面は「返った値が右端より先なら合わせる」ので、毎回の切り上げの端数が積もって戻らなかった(2026-11-04 の段階化の確認中に発見。前からある欠陥) | 右端を合わせるのは初回(範囲を指定しない要求)の応答だけにする | **解消(2026-11-04)**。再現テスト `does not let the live right edge creep ahead of wall time` を追加(修正前は失敗)。アプリで 100 Hz・3 分後もデータが右端まで届くことを確認 | | **PF-7** | **欠陥** | **Disconnect → Clear → Connect で、旧セッションのチャンネルがプロッタに残り、新しいデータと混ざる。** ポートを閉じた状態の Clear でストアが無くなり、次の Connect が「初回の接続」扱いになって、プロッタが空にされなかった | SYS-F-802 / 806 | PF-5 の相談で Fable が発見(コードの読解) | ストアが外れた時点でプロッタを空にし、以後の接続を新しいセッションとして扱う | **解消(2026-11-04)**。`test_detach_clears_the_plot` | +| **PF-8** | **欠陥** | **データを受信した後でプロッタを開くと、それまでのデータが時刻 0 の 1 点に潰れる。** 10 行が 1 点、500 行も 1 点になった。並列テストで `test_batch_timestamps_are_spread` がまれに落ちていたのも同じ原因 | SYS-F-808 | プロッタの時刻の原点が「プロッタがストアに接続した時点」で、溜まっていた行をすべて経過 0 ms で読み、1 ms ずつ過去へ広げる処理が 0 で止まっていた。テスト 2 件で決定的に再現 | 原点をストアの作成時刻にし、溜まっていた行はストアの受信時刻の記録(約 100 ms ごと)で切って、その時刻に置く。記録が時計と合わない時(壁時計の補正)は今の時刻に戻す(Fable レビュー済み) | **修正済**(2026-11-04、実アプリで 10 秒分を後から開いて確認) | ## 4. 完了 @@ -111,7 +112,7 @@ | — | 検証 | **E-17**: Inspect 中は新しいデータが来ても表示範囲が動かない(ホイールで Inspect に入り、5 秒あけて 2 枚撮影) | 2026-11-04 | PASS。グラフと State Timeline の領域でピクセル差 0 / 179,349。「▶LIVE」ボタンとフッターの「Inspect」表示も確認。§3 の方法 | | — | 検証 | **E-19**: 外れ値で Y 軸が即座に拡大し、窓から出て 3 秒ほどで縮小する | 2026-11-04 | **PASS**。当初は Average モードでのみ PASS(LTTB では潰れた値約 41 までしか広がらなかった。PF-1)。Peak への置換後、既定モードでも 30 → 500 に即拡大し、窓から出た後に 30 へ戻ることを再確認 | | PF-1 | 検証 | Peak への置換後の再確認: 画面評価と同じ信号(100 Hz、外れ値 400、7.3 秒ごとの 6.0 のスパイク)を流して撮影 | 2026-11-04 | **PASS**。10 秒窓で外れ値 400・スパイク 6.0 とも元の値で描かれた(置換前は約 41・約 1.7)。300 秒窓でも外れ値は 400、6.0 のスパイクはすべて残った(置換前は約 9、6.0 は消失)。ノイズの細かい形も見えるようになった | -| — | 決定 | Auto Reconnect(SYS-F-807)と US-02「再接続でセッションが切り替わってほしい」の関係 | 2026-11-04 | **衝突しない**(オーナー判断)。Auto Reconnect は利用者が ON / OFF を切り替えられるので、切り替わってほしい人は OFF にすれば US-02 が満たされ、リセット前のログを残したい人は ON にする。VA-4 は OFF での確認に絞った | +| — | 決定 | Auto Connect(SYS-F-807)と US-02「再接続でセッションが切り替わってほしい」の関係 | 2026-11-04 | **衝突しない**(オーナー判断)。Auto Connect は利用者が ON / OFF を切り替えられるので、切り替わってほしい人は OFF にすれば US-02 が満たされ、リセット前のログを残したい人は ON にする。VA-4 は OFF での確認に絞った | | — | 検証 | MCP Server ON のままの 12 Mbaud 欠落ゼロと索引の追随(docs/26 §9 / DEBT-9 (4))。24 §6.5 T2-5 に「未実施」と残っていた | 2026-11-04 | PASS。[24 §6.5](24_vv_plan.md) T2-5c(本書の起票前に完了していた。記述の更新漏れ) | | — | 検証 | 表示名の変更(AI Bridge → MCP Server)後の UIA E2E と MCP stdio スモーク | 2026-11-04 | PASS。`run_bridge_e2e.ps1`(UIA が新ラベルでトグルを操作)、`mcp_stdio_smoke.py`(リリースバイナリ) | diff --git a/docs/THIRD_PARTY_RUST.md b/docs/THIRD_PARTY_RUST.md index 3eab19e..b210677 100644 --- a/docs/THIRD_PARTY_RUST.md +++ b/docs/THIRD_PARTY_RUST.md @@ -429,7 +429,7 @@ [unescaper@0.1.8](https://github.com/hack-ink/unescaper) - MIT License [is-docker@0.2.0](https://github.com/TheLarkInn/is-docker) - MIT License [is-wsl@0.4.0](https://github.com/TheLarkInn/is-wsl) - MIT License -[serial-monitor-essential@0.1.0](https://crates.io/crates/serial-monitor-essential) - MIT License +[serial-monitor-essential@0.1.0-beta.2](https://crates.io/crates/serial-monitor-essential) - MIT License [block2@0.6.2](https://github.com/madsmtm/objc2) - MIT License [brotli-decompressor@5.0.0](https://github.com/dropbox/rust-brotli-decompressor) - MIT License [convert_case@0.4.0](https://github.com/rutrum/convert-case) - MIT License diff --git a/mcp/server.mjs b/mcp/server.mjs index 333f686..15e8c86 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -100,7 +100,7 @@ function timeoutMessage(method, ms) { export function connectedLabel(res) { if (res.connected === true) return 'yes'; if (res.reconnecting === true) { - return 'no (device went away; the app reconnects when it returns and the capture continues at the same offsets)'; + return 'no (Auto Connect is waiting for the device; the app connects when it is available and the capture continues at the same offsets)'; } return 'no'; } diff --git a/mcp/smoke.mjs b/mcp/smoke.mjs index 4297228..821a97a 100644 --- a/mcp/smoke.mjs +++ b/mcp/smoke.mjs @@ -574,7 +574,7 @@ function testConnectedLabel() { 'status shows a pending reconnect so the agent knows to wait', connectedLabel({ connected: true }) === 'yes' && connectedLabel({ connected: false }) === 'no' && - connectedLabel({ connected: false, reconnecting: true }).startsWith('no (device went away;'), + connectedLabel({ connected: false, reconnecting: true }).startsWith('no (Auto Connect is waiting'), connectedLabel({ connected: false, reconnecting: true }) ); } diff --git a/package-lock.json b/package-lock.json index 9dda616..9fe8b4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "serial-monitor-essential", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "serial-monitor-essential", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2", diff --git a/package.json b/package.json index 175435f..71f7c40 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "serial-monitor-essential", "private": true, - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "license": "MIT", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 914998e..0cc6a7e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3594,7 +3594,7 @@ dependencies = [ [[package]] name = "serial-monitor-essential" -version = "0.1.0-beta.1" +version = "0.1.0-beta.2" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 797feb6..5209e7e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "serial-monitor-essential" -version = "0.1.0-beta.1" +version = "0.1.0-beta.2" description = "Fast serial monitor and plotter for embedded development" authors = ["771-8bit"] edition = "2021" diff --git a/src-tauri/src/bridge.rs b/src-tauri/src/bridge.rs index 5824aa8..bc18605 100644 --- a/src-tauri/src/bridge.rs +++ b/src-tauri/src/bridge.rs @@ -470,7 +470,7 @@ fn method_help() -> Value { "methods": { "help": { "params": {}, "result": "this document" }, "auth": { "params": { "token": "string" }, "result": { "authenticated": true } }, - "status": { "params": {}, "result": { "connected": "bool", "reconnecting": "bool (the device went away and the app reopens it when it returns; the capture continues at the same offsets, no reset)", "port_name": "string|null", "total_bytes": "u64", "app_version": "string", "protocol": "u32" } }, + "status": { "params": {}, "result": { "connected": "bool", "reconnecting": "bool (Auto Connect is waiting for the selected device: it went away, is not plugged in yet, or is in use elsewhere; the app opens it when it can, and the capture continues at the same offsets, no reset)", "port_name": "string|null", "total_bytes": "u64", "app_version": "string", "protocol": "u32" } }, "read_range": { "params": { "offset": "u64", "length": "u32 (clamped to 1 MiB and to available bytes)" }, "result": { "base64": "string", "offset": "u64", "length_read": "u32" } }, "tail": { "params": { "bytes": "u32 (default 4096, max 1 MiB)" }, "result": { "base64": "string", "offset": "u64 (stream offset of the returned data)", "total_bytes": "u64" } }, "send": { "params": "either {text, line_ending: none|cr|lf|crlf} or {base64}", "result": { "bytes_written": "usize", "offset_before": "u64 (stream offset when the app started writing; pass it to wait_for as 'from' to catch a reply that lands immediately)" }, "note": "writes to the port the app has open; every send is shown to the human in the GUI. Reception keeps running while it writes. With hardware flow control the peer may throttle, so a large send can take long; it fails only if no byte is accepted for 3 s (the error says how many bytes went out). Do not resend on your own timeout: the bytes may already be on the wire." }, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 340d5ab..1620d2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -90,6 +90,7 @@ pub fn run() { // AI ブリッジ: 既定 OFF。ここでは状態を登録するだけで自動起動はしない。 .manage(bridge::BridgeState::new()) .invoke_handler(tauri::generate_handler![ + set_window_theme, serial::list_ports, serial::open_port, serial::close_port, @@ -100,6 +101,7 @@ pub fn run() { serial::get_display_rows, serial::get_ascii_lines, serial::export_log, + serial::export_text_with_timestamps, serial::clear_data, serial::get_clipboard_text, serial::get_line_index, @@ -122,6 +124,19 @@ pub fn run() { } /// Open the plotter window (single instance) +/// Title bar theme of the calling window (issue #6): "dark" / "light", or +/// anything else to follow the OS. Each window calls this for itself, so no +/// window permission is needed. +#[tauri::command] +fn set_window_theme(window: tauri::WebviewWindow, theme: Option) -> Result<(), String> { + let theme = match theme.as_deref() { + Some("dark") => Some(tauri::Theme::Dark), + Some("light") => Some(tauri::Theme::Light), + _ => None, + }; + window.set_theme(theme).map_err(|e| e.to_string()) +} + #[tauri::command] async fn open_plotter_window( app: tauri::AppHandle, diff --git a/src-tauri/src/mcp_stdio.rs b/src-tauri/src/mcp_stdio.rs index 9ace78f..0135f92 100644 --- a/src-tauri/src/mcp_stdio.rs +++ b/src-tauri/src/mcp_stdio.rs @@ -90,7 +90,7 @@ fn unreachable_message(host: &str, port: u16, detail: &str) -> String { fn connected_label(connected: bool, reconnecting: bool) -> &'static str { match (connected, reconnecting) { (true, _) => "yes", - (false, true) => "no (device went away; the app reconnects when it returns and the capture continues at the same offsets)", + (false, true) => "no (Auto Connect is waiting for the device; the app connects when it is available and the capture continues at the same offsets)", (false, false) => "no", } } @@ -2414,7 +2414,10 @@ mod tests { r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"serial_status"}}"#; let res = rpc(&mut bridge, call); let text = res["result"]["content"][0]["text"].as_str().unwrap(); - assert!(text.contains("connected: no (device went away;"), "{text}"); + assert!( + text.contains("connected: no (Auto Connect is waiting"), + "{text}" + ); let res = rpc(&mut bridge, call); let text = res["result"]["content"][0]["text"].as_str().unwrap(); assert!(text.starts_with("connected: no\n"), "{text}"); diff --git a/src-tauri/src/plotter/parser.rs b/src-tauri/src/plotter/parser.rs index 62e711a..004cf84 100644 --- a/src-tauri/src/plotter/parser.rs +++ b/src-tauri/src/plotter/parser.rs @@ -4,7 +4,8 @@ // - Single values: `123.45\r\n` // - CSV format: `10,20,30\r\n` // - Tab/space separated: `10\t20\t30\r\n` -// - Labeled values: `temp:25.5,humidity:60\r\n` +// - Labeled values: `temp:25.5,humidity:60\r\n`, `V=30.58 I=0.8\r\n`, +// `temp = 25 hum: 60\r\n`, `temp 25.5 hum 60\r\n` (see `parse_line`) // - Header detection: first line with all non-numeric values // - State values: `state:RUNNING\r\n` @@ -218,11 +219,16 @@ impl PlotterParser { // banner like "Boot OK") from permanently locking in the wrong // separator for all subsequent data. let separator = self.detect_separator(line); + let whitespace_mode = separator == ' '; // For space separation, collapse runs of spaces (printf-style aligned // output) instead of producing empty parts that shift column indices. - let parts: Vec<&str> = if separator == ' ' { - line.split_whitespace().collect() + // A joiner with spaces around it ("temp = 25", "temp: 25") is closed up + // first so the pair stays one token. + let closed_up; + let parts: Vec<&str> = if whitespace_mode { + closed_up = close_up_joiners(line); + closed_up.split_whitespace().collect() } else { line.split(separator).map(|s| s.trim()).collect() }; @@ -246,27 +252,60 @@ impl PlotterParser { return None; // Don't return data point for header } - // Parse values + // Classify every field: a named pair ("name=value" / "name:value") or a + // bare token. A pair whose value is empty or unusable ("key=", + // "I=0.825/0.797") is dropped outright: it must not fall back to a + // positional State channel such as "ch3 = key=". + let mut fields: Vec = parts + .iter() + .map(|part| match split_pair(part) { + Some((name, value)) => match classify_value(value) { + Some(v) => Field::Pair(name.to_string(), v), + None => Field::Dropped, + }, + None => Field::Bare(part), + }) + .collect(); + + // Space as the joiner ("temp 25.5 hum 60"): only in whitespace mode, + // only without an adopted header (its labels name the columns), and only + // when EVERY bare number directly follows a bare word - otherwise the line + // is positional as before ("RUNNING 25.5 60", "25.5 60 RUNNING"). + if whitespace_mode && !self.header_detected { + pair_words_with_numbers(&mut fields); + } + + // In a whitespace line with named pairs, leftover bare words are prose or + // decoration ("[Mon", "occurred"), not a column of states. + let has_pairs = fields.iter().any(|f| matches!(f, Field::Pair(..))); + let drop_bare_words = whitespace_mode && has_pairs; + let mut channels = HashMap::new(); let mut channel_order = Vec::new(); - for (i, part) in parts.iter().enumerate() { - // Check for labeled value (label:value format) - if let Some((label, value)) = self.parse_labeled_value(part) { - channels.insert(label.clone(), value); - channel_order.push(label); - } else { - // Auto-generate label - let label = if i < self.labels.len() { - self.labels[i].clone() - } else { - format!("ch{}", i) - }; - - if let Some(value) = self.parse_value(part) { + for (i, field) in fields.into_iter().enumerate() { + match field { + Field::Pair(label, value) => { + channels.insert(label.clone(), value); + channel_order.push(label); + } + Field::Bare(part) => { + let Some(value) = classify_value(part) else { + continue; + }; + if drop_bare_words && !matches!(value, ChannelValue::Numeric(_)) { + continue; + } + // Auto-generate label + let label = if i < self.labels.len() { + self.labels[i].clone() + } else { + format!("ch{}", i) + }; channels.insert(label.clone(), value); channel_order.push(label); } + Field::Dropped => {} } } @@ -308,8 +347,8 @@ impl PlotterParser { return false; } - // If any part contains ':', it's a labeled value line, not a header - if parts.iter().any(|part| part.contains(':')) { + // If any part contains ':' or '=', it's a labeled value line, not a header + if parts.iter().any(|part| part.contains([':', '='])) { return false; } @@ -319,44 +358,130 @@ impl PlotterParser { !trimmed.is_empty() && trimmed.parse::().is_err() }) } +} - /// Parse a labeled value (label:value format) - fn parse_labeled_value(&self, s: &str) -> Option<(String, ChannelValue)> { - let parts: Vec<&str> = s.splitn(2, ':').collect(); - if parts.len() != 2 { - return None; - } +/// One field of a line, before labels are assigned +enum Field<'a> { + /// "name=value" / "name:value" (or a word followed by a number) + Pair(String, ChannelValue), + /// A token without a joiner (labelled by its column) + Bare(&'a str), + /// A pair whose value is empty or unusable + Dropped, +} - let label = parts[0].trim().to_string(); - let value_str = parts[1].trim(); +/// Whether `s` can be a channel name: it starts with a letter (any script, so +/// "温度" works) or '_'. This keeps a time of day ("12:34:56") or "3:5" from +/// being read as name and value. +fn is_name(s: &str) -> bool { + s.chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_') +} - if label.is_empty() { - return None; +/// Split "name=value" / "name:value" at the first '=' or ':'. None when there is +/// no joiner or the part before it is not a name. +fn split_pair(field: &str) -> Option<(&str, &str)> { + let at = field.find(['=', ':'])?; + let name = field[..at].trim(); + is_name(name).then(|| (name, field[at + 1..].trim())) +} + +/// Remove the whitespace around '=' and ':' so "temp = 25" and "temp: 25" +/// survive the whitespace split as one token. +fn close_up_joiners(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut after_joiner = false; + for c in line.chars() { + if c == '=' || c == ':' { + let kept = out.trim_end().len(); + out.truncate(kept); + out.push(c); + after_joiner = true; + } else if after_joiner && c.is_whitespace() { + // skip + } else { + out.push(c); + after_joiner = false; } + } + out +} - let value = self.parse_value(value_str)?; - Some((label, value)) +/// Pair "word number" in place when every bare number in the line directly +/// follows a bare word (see `parse_line`). Leaves the fields unchanged otherwise. +fn pair_words_with_numbers(fields: &mut Vec) { + let is_number = |f: &Field| matches!(f, Field::Bare(t) if matches!(classify_value(t), Some(ChannelValue::Numeric(_)))); + let is_word = |f: &Field| matches!(f, Field::Bare(t) if is_name(t)); + + let numbers: Vec = (0..fields.len()) + .filter(|&i| is_number(&fields[i])) + .collect(); + if numbers.is_empty() || numbers.iter().any(|&i| i == 0 || !is_word(&fields[i - 1])) { + return; + } + for &i in numbers.iter().rev() { + let (Field::Bare(word), Field::Bare(num)) = (&fields[i - 1], &fields[i]) else { + continue; + }; + let Some(value) = classify_value(num) else { + continue; + }; + fields[i - 1] = Field::Pair(word.to_string(), value); + fields.remove(i); } +} - /// Parse a value as numeric or state - fn parse_value(&self, s: &str) -> Option { - let trimmed = s.trim(); - if trimmed.is_empty() { - return None; +/// Longest unit suffix accepted after a number ("ms", "V", "%", "dB", "]") +const MAX_UNIT_CHARS: usize = 4; + +/// Interpret a value: +/// - a finite number → Numeric +/// - starting with a letter ("RUN", "MODE2", "NaN") → State +/// - a number followed by a short unit without digits ("11ms", "3.3V", "100%", +/// "2916094]") → Numeric, unit dropped +/// - anything else that is neither ("0.825/0.797", "1/1", "12:34:56", "-inf") +/// → None: as a State it would be a new value on every line, filling the +/// state timeline with noise +fn classify_value(s: &str) -> Option { + let s = s.trim(); + let first = s.chars().next()?; + if let Ok(num) = s.parse::() { + if num.is_finite() { + return Some(ChannelValue::Numeric(num)); } - - // Try parsing as number - if let Ok(num) = trimmed.parse::() { - // Only accept finite numbers as Numeric - if num.is_finite() { - return Some(ChannelValue::Numeric(num)); + } + // A joiner left inside a value ("accel:0.049", "a=b") means the field + // was not one name/value pair: neither a state nor a number + if s.contains(['=', ':']) { + return None; + } + if first.is_alphabetic() || first == '_' { + return Some(ChannelValue::State(s.to_string())); + } + // Number + unit: try the split points from the longest number down + let unit_ok = |u: &str| { + u.chars().count() <= MAX_UNIT_CHARS + && !u + .chars() + .any(|c| c.is_ascii_digit() || c == '=' || c == ':') + }; + for (at, _) in s.char_indices().rev().filter(|&(i, _)| i > 0) { + let (num, unit) = s.split_at(at); + if !unit_ok(unit) { + // A longer unit only gets longer: no shorter number can work + if unit.chars().count() > MAX_UNIT_CHARS { + break; + } + continue; + } + if let Ok(v) = num.parse::() { + if v.is_finite() { + return Some(ChannelValue::Numeric(v)); } - // Non-finite (NaN, Infinity) fall through to State } - - // Treat as state value - Some(ChannelValue::State(trimmed.to_string())) } + None } #[cfg(test)] @@ -1070,6 +1195,10 @@ mod tests { /// (Japanese) names so that chunk boundaries can land mid-character. const LABEL_POOL: &[&str] = &["temp", "humidity", "rpm", "温度", "湿度", "モータ"]; + /// Name/value joiners and pair separators the parser accepts + const JOINERS: &[&str] = &[":", "=", " = ", ": ", " :"]; + const SEPARATORS: &[&str] = &[",", " ", "\t", ", ", " "]; + /// State words emitted as bare lines or as labeled values (ASCII + Japanese). const STATE_POOL: &[&str] = &["RUNNING", "STOPPED", "OK", "ERROR", "運転中", "停止"]; @@ -1103,6 +1232,19 @@ mod tests { .map(|(l, v)| format!("{}:{}", l, v)) .collect::>() .join(",")), + // Labeled row with any joiner and separator (`V=30.58 I=0.8`, `a = 1\tb: 2`) + 3 => ( + prop::collection::vec( + (prop::sample::select(LABEL_POOL), prop::sample::select(JOINERS), value_token()), + 1..=4, + ), + prop::sample::select(SEPARATORS), + ) + .prop_map(|(pairs, sep)| pairs + .into_iter() + .map(|(l, j, v)| format!("{}{}{}", l, j, v)) + .collect::>() + .join(sep)), // Bare state word (banner / status line) 2 => prop::sample::select(STATE_POOL).prop_map(|s| s.to_string()), // Junk text: any short valid-UTF-8 run with no line endings @@ -1182,6 +1324,46 @@ mod tests { proptest! { #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] + /// P4 - named pairs in any joiner/separator style come back exactly: + /// every pair becomes its name (in order) with its value, numbers with a + /// unit suffix keep their number, and no other channel appears. + #[test] + fn prop_named_pairs_roundtrip( + pairs in prop::collection::vec( + ( + prop::sample::select(LABEL_POOL), + prop::sample::select(JOINERS), + -1.0e6f64..1.0e6f64, + 0usize..=3, + prop::sample::select(&["", "ms", "V", "%", "dB", "]"][..]), + ), + 1..=5, + ), + sep in prop::sample::select(SEPARATORS), + ) { + let fields: Vec = pairs + .iter() + .map(|(l, j, v, p, unit)| format!("{}{}{:.*}{}", l, j, p, v, unit)) + .collect(); + let line = format!("{}\n", fields.join(sep)); + + let mut parser = PlotterParser::new(); + let points = parser.parse(line.as_bytes(), PROP_TS); + + prop_assert_eq!(points.len(), 1, "line {:?}", line); + let want_names: Vec = pairs.iter().map(|(l, ..)| l.to_string()).collect(); + prop_assert_eq!(&points[0].channel_order, &want_names, "line {:?}", line); + // With duplicate names the last value wins (HashMap insert) + if let Some((l, _, v, p, _)) = pairs.last() { + let want = format!("{:.*}", p, v).parse::().unwrap(); + if let Some(ChannelValue::Numeric(got)) = points[0].channels.get(*l) { + prop_assert_eq!(*got, want, "{} in {:?}", l, line); + } else { + prop_assert!(false, "{} not numeric in {:?}", l, line); + } + } + } + /// P1 - THE key property: chunking invariance. /// /// Guards: split-read handling. Feeding a byte stream through one @@ -1400,4 +1582,186 @@ mod tests { let points = parser.parse(b"cos:accel:0.049,temp:24\n1,2\n", 0); assert_eq!(names(&points), vec!["ch0", "ch1"]); } + + // ---- Flexible name/value recognition (owner request after issue #3's data) ---- + + fn parse_one(line: &str) -> Option { + let mut parser = PlotterParser::new(); + let mut points = parser.parse(format!("{}\n", line).as_bytes(), 0); + assert!(points.len() <= 1); + points.pop() + } + + fn num(p: &ParsedDataPoint, name: &str) -> Option { + match p.channels.get(name) { + Some(ChannelValue::Numeric(v)) => Some(*v), + _ => None, + } + } + + /// The tester's firmware line: `name=value` pairs separated by spaces + #[test] + fn test_equals_pairs_from_real_firmware() { + let line = "[Mon t=2916094] V=30.58 Vdc=12.37 I=0.825/0.797 ph=49.5/1 M=0.86996 \ + phi=-1.35 pll=49.9996 pe=-3.2 r=1/1 age=11ms rdy=1 cap=4084 md=687 sd=23"; + let p = parse_one(line).unwrap(); + assert_eq!( + p.channel_order, + vec!["t", "V", "Vdc", "M", "phi", "pll", "pe", "age", "rdy", "cap", "md", "sd"] + ); + assert_eq!(num(&p, "t"), Some(2916094.0)); + assert_eq!(num(&p, "V"), Some(30.58)); + assert_eq!(num(&p, "phi"), Some(-1.35)); + assert_eq!(num(&p, "age"), Some(11.0)); + // "0.825/0.797" etc. are neither numbers nor states; "[Mon" is decoration + for gone in ["I", "ph", "r", "ch0", "[Mon"] { + assert!(!p.channels.contains_key(gone), "{gone}"); + } + } + + /// ...and as the first line it is data, not a header (also when untrusted) + #[test] + fn test_equals_line_is_never_a_header() { + let mut parser = PlotterParser::new(); + let points = parser.parse(b"a=x b=y\n", 0); + assert_eq!(names(&points), vec!["a", "b"]); + + let mut parser = PlotterParser::new(); + parser.begin_untrusted_segment(); + let points = parser.parse(b"V=1 I=2\n1,2\n", 0); + assert_eq!(names(&points), vec!["ch0", "ch1"]); + } + + #[test] + fn test_joiners_with_spaces() { + for line in [ + "temp = 25 hum = 60", + "temp: 25 hum: 60", + "temp =25 hum= 60", + "temp = 25, hum = 60", + "temp=25\thum:60", + "temp 25 hum 60", + ] { + let p = parse_one(line).unwrap(); + assert_eq!(p.channel_order, vec!["temp", "hum"], "{line}"); + assert_eq!(num(&p, "temp"), Some(25.0), "{line}"); + assert_eq!(num(&p, "hum"), Some(60.0), "{line}"); + } + } + + #[test] + fn test_units_after_pairs_are_dropped() { + let p = parse_one("Temp: 25.3 C Hum: 60 %").unwrap(); + assert_eq!(p.channel_order, vec!["Temp", "Hum"]); + assert_eq!(num(&p, "Temp"), Some(25.3)); + } + + #[test] + fn test_time_of_day_is_not_a_pair() { + for line in ["12:34:56 temp=25", "[12:34:56] temp=25"] { + let p = parse_one(line).unwrap(); + assert_eq!(p.channel_order, vec!["temp"], "{line}"); + } + assert!(parse_one("t=12:34:56").is_none()); + } + + #[test] + fn test_value_classification() { + let n = |s: &str| classify_value(s); + for (s, v) in [ + ("11ms", 11.0), + ("3.3V", 3.3), + ("100%", 100.0), + ("-3.2dB", -3.2), + ("2916094]", 2916094.0), + ("1e3", 1000.0), + ] { + assert_eq!(n(s), Some(ChannelValue::Numeric(v)), "{s}"); + } + for s in ["RUN", "MODE2", "E01", "NaN", "運転中"] { + assert_eq!(n(s), Some(ChannelValue::State(s.to_string())), "{s}"); + } + for s in [ + "0.825/0.797", + "1/1", + "0x1F", + "-inf", + "12:34:56", + "", + "[Mon", + "accel:0.049", + ] { + assert_eq!(n(s), None, "{s}"); + } + } + + /// `key=` with no value disappears; it does not become a positional State + #[test] + fn test_empty_values_are_dropped() { + // (a value-less "k=" directly before another pair is closed up into it: + // "k= x=1" reads as k = "x=1", which is dropped as well) + for line in ["x=1 k=", "x=1 k:", "k=,x=1"] { + let p = parse_one(line).unwrap(); + assert_eq!(p.channel_order, vec!["x"], "{line}"); + } + assert!(parse_one("k=").is_none()); + } + + /// Space joiner only when every bare number follows a word + #[test] + fn test_space_joiner_guards() { + let p = parse_one("25.5 60 RUNNING").unwrap(); + assert_eq!(p.channel_order, vec!["ch0", "ch1", "ch2"]); + assert_eq!( + p.channels.get("ch2"), + Some(&ChannelValue::State("RUNNING".into())) + ); + + let p = parse_one("RUNNING 25.5 60").unwrap(); + assert_eq!(p.channel_order, vec!["ch0", "ch1", "ch2"]); + + // With a header the columns are named by it + let mut parser = PlotterParser::new(); + let points = parser.parse(b"state temp hum\nON 25.5 60\n", 0); + assert_eq!(points[0].channel_order, vec!["state", "temp", "hum"]); + assert_eq!( + points[0].channels.get("state"), + Some(&ChannelValue::State("ON".into())) + ); + + // Prose: the number is named by the word before it, the rest is dropped + let p = parse_one("Error 5 occurred").unwrap(); + assert_eq!(p.channel_order, vec!["Error"]); + assert_eq!(num(&p, "Error"), Some(5.0)); + } + + /// In comma/tab lines a bare word stays a positional state column + #[test] + fn test_bare_state_column_kept_in_csv() { + let p = parse_one("temp:25.5,RUNNING").unwrap(); + assert_eq!(p.channel_order, vec!["temp", "ch1"]); + assert_eq!( + p.channels.get("ch1"), + Some(&ChannelValue::State("RUNNING".into())) + ); + } + + #[test] + fn test_header_rules() { + let mut parser = PlotterParser::new(); + assert!(parser.parse(b"a,b,c\n", 0).is_empty()); // header + let mut parser = PlotterParser::new(); + assert!(parser.parse(b"temp hum\n", 0).is_empty()); // header + let mut parser = PlotterParser::new(); + assert_eq!(names(&parser.parse(b"a=1,b=2\n", 0)), vec!["a", "b"]); + } + + /// Names start with a letter (any script) or '_' + #[test] + fn test_name_rule() { + assert_eq!(parse_one("温度=25").unwrap().channel_order, vec!["温度"]); + assert_eq!(parse_one("_x=1").unwrap().channel_order, vec!["_x"]); + // A digit-first label is not a name: the field is not a pair + assert!(parse_one("3V3=3.3").is_none()); + } } diff --git a/src-tauri/src/plotter/thread.rs b/src-tauri/src/plotter/thread.rs index 9650acd..83a55e8 100644 --- a/src-tauri/src/plotter/thread.rs +++ b/src-tauri/src/plotter/thread.rs @@ -48,14 +48,31 @@ fn should_log_read_failure(read_failures: u32) -> bool { /// Spread the timestamps of one parsed batch across (prev, now], ~1ms/line, /// clamped so ordering stays monotonic with the previous batch. /// Returns the new last_batch_ts. (Pure - extracted for exact unit testing.) +#[cfg(test)] fn spread_batch_timestamps( data_points: &mut [ParsedDataPoint], last_batch_ts: u64, timestamp_ms: u64, +) -> u64 { + spread_over_window(data_points, last_batch_ts, timestamp_ms, 0) +} + +/// How often the store records "bytes received by when" (the UI notifier's +/// 16 ms x 6): the bytes of a recorded slice arrived within this window +const RECORD_WINDOW_MS: u64 = 100; + +/// `spread_batch_timestamps` over a window of at least `window_ms` before +/// `timestamp_ms`: a slice timed by a store record holds the lines of up to +/// ~100 ms, spread evenly across it instead of bunched into its last few ms. +fn spread_over_window( + data_points: &mut [ParsedDataPoint], + last_batch_ts: u64, + timestamp_ms: u64, + window_ms: u64, ) -> u64 { let n = data_points.len() as u64; if n > 0 { - let candidate = timestamp_ms.saturating_sub(n); + let candidate = timestamp_ms.saturating_sub(n.max(window_ms)); let prev = candidate.max(last_batch_ts).min(timestamp_ms); let span = timestamp_ms - prev; for (i, point) in data_points.iter_mut().enumerate() { @@ -67,6 +84,60 @@ fn spread_batch_timestamps( } } +/// Timestamp (ms since the session started) for a slice of received bytes. +/// +/// `entry_wall` is the wall-clock time by which the slice had arrived, from the +/// store's timestamp index (None: no record yet, i.e. live bytes). Records and the +/// live clock (an `Instant`) are both measured from the store's creation, but the +/// wall clock is slewed and can jump, so a record that would go backwards past the +/// previous batch or ahead of now falls back to now - live reading then behaves +/// exactly as without records. (Pure - extracted for exact unit testing.) +fn slice_timestamp( + entry_wall: Option, + created_wall: u64, + last_batch_ts: u64, + now: u64, +) -> u64 { + match entry_wall { + Some(t) => { + let ts = t.saturating_sub(created_wall); + if ts < last_batch_ts || ts > now { + now + } else { + ts + } + } + None => now, + } +} + +/// Parse the bytes `range` (store offsets) of `data` (read from offset `base`), +/// telling the parser where a new receive segment starts (connect / +/// auto-reconnect): the line there may be cut (docs/27 PF-2). Parser state carries +/// over between calls, so a line crossing `range.end` is completed by the next one. +fn parse_range( + parser: &mut PlotterParser, + store: &DataStore, + data: &[u8], + base: u64, + range: std::ops::Range, + ts: u64, +) -> Vec { + let mut points = Vec::new(); + let mut pos = (range.start - base) as usize; + let end = (range.end - base) as usize; + for start in store.segment_starts_in(range.start, range.end) { + let cut = (start - base) as usize; + if cut > pos { + points.extend(parser.parse(&data[pos..cut], ts)); + } + parser.begin_untrusted_segment(); + pos = cut; + } + points.extend(parser.parse(&data[pos..end], ts)); + points +} + /// Plotter thread for background parsing pub struct PlotterThread { /// Thread handle @@ -78,11 +149,20 @@ pub struct PlotterThread { impl PlotterThread { /// Start a new plotter thread with PlotterAggregator pub fn start(store_handle: SharedDataStore, aggregator: PlotterAggregator) -> Self { + Self::start_with_read_cap(store_handle, aggregator, MAX_READ_SIZE) + } + + /// `start` with a smaller read size, so tests can make a backlog span reads + fn start_with_read_cap( + store_handle: SharedDataStore, + aggregator: PlotterAggregator, + read_cap: u64, + ) -> Self { let stop_flag = Arc::new(AtomicBool::new(false)); let stop_flag_clone = Arc::clone(&stop_flag); let handle = thread::spawn(move || { - Self::run(store_handle, aggregator, stop_flag_clone); + Self::run(store_handle, aggregator, stop_flag_clone, read_cap); }); Self { @@ -110,13 +190,16 @@ impl PlotterThread { store_handle: SharedDataStore, aggregator: PlotterAggregator, stop_flag: Arc, + read_cap: u64, ) { let mut parser = PlotterParser::new(); let mut last_processed_offset: u64 = 0; - // Time origin of the plotter's timestamps: the moment the current - // session's store was attached (docs/27 PF-5). A new session (manual - // Connect / Clear) starts again at 0; an automatic reconnect keeps the - // same store and therefore the same clock. + // Time origin of the plotter's timestamps: the creation of the current + // session's store (docs/27 PF-5), not the moment the plotter attached - + // the plotter is often opened after data has arrived, and that backlog + // must keep its reception times. A new session (manual Connect / Clear) + // starts again at 0; an automatic reconnect keeps the same store and + // therefore the same clock. let mut start_time = Instant::now(); // Whether this thread has ever been attached to a store: from then on a // swap or a detach ends a session, so the plot must be cleared @@ -162,7 +245,7 @@ impl PlotterThread { aggregator.clear(); } had_store = true; - start_time = Instant::now(); + start_time = store.created_instant(); last_batch_ts = 0; current_store = Some(Arc::clone(&store)); } @@ -183,42 +266,101 @@ impl PlotterThread { // Check for new data let total_bytes = data_store.total_bytes(); - if let Some(bytes_to_read) = bytes_to_read(total_bytes, last_processed_offset) { + if let Some(bytes_to_read) = bytes_to_read(total_bytes, last_processed_offset) + .map(|n| n.min(read_cap.min(u32::MAX as u64) as u32)) + { // Read new data from data store match data_store.get_data(last_processed_offset, bytes_to_read) { Ok(data) => { read_failures = 0; if !data.is_empty() { - // Calculate timestamp (ms since start) - let timestamp_ms = start_time.elapsed().as_millis() as u64; - - // Parse the data, telling the parser where a new - // receive segment starts (connect / auto-reconnect): - // the line there may be cut (docs/27 PF-2) - let mut data_points: Vec = Vec::new(); - let mut pos = 0usize; + let now_ms = start_time.elapsed().as_millis() as u64; let end = last_processed_offset + data.len() as u64; - for start in data_store.segment_starts_in(last_processed_offset, end) { - let cut = (start - last_processed_offset) as usize; - if cut > pos { - data_points.extend(parser.parse(&data[pos..cut], timestamp_ms)); + let created_wall = data_store.created_wall_ms(); + + // Cut the read where the store recorded how many bytes + // had arrived by when (about every 100 ms), so a + // backlog - the plotter opened after data arrived, or a + // read capped at MAX_READ_SIZE - keeps its reception + // times instead of collapsing onto "now". The tail of + // the read takes the NEXT record's time (it may lie + // beyond this read); only bytes newer than every + // record are timed "now". + let mut cuts: Vec<(u64, Option)> = data_store + .timestamps_in(last_processed_offset, end) + .into_iter() + .filter(|e| e.cumulative_bytes < end) + .map(|e| (e.cumulative_bytes, Some(e.timestamp))) + .collect(); + cuts.push(( + end, + data_store.timestamp_at_or_after(end).map(|e| e.timestamp), + )); + + // Slices that end up with the same time (e.g. several + // falling back to "now") are spread together, so their + // lines don't pile up on one instant. + let mut data_points: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + let mut pending_ts = last_batch_ts; + // Whether the pending slices are timed by a store record + let mut pending_recorded = false; + let mut from = last_processed_offset; + for (to, wall) in cuts { + if to <= from { + continue; + } + // Never before the slice still pending (keeps order) + let floor = last_batch_ts.max(pending_ts); + let ts = slice_timestamp(wall, created_wall, floor, now_ms); + let recorded = + wall.map(|t| t.saturating_sub(created_wall)) == Some(ts); + let points = parse_range( + &mut parser, + &data_store, + &data, + last_processed_offset, + from..to, + ts, + ); + from = to; + if points.is_empty() { + continue; } - parser.begin_untrusted_segment(); - pos = cut; + if ts != pending_ts && !pending.is_empty() { + // Spread timestamps across (prev, ts] so the lines + // keep distinct, monotonically increasing x + // positions. Aim for ~1ms per line, but never go + // below the previous batch's end (keeps + // aggregator input ordered). + last_batch_ts = spread_over_window( + &mut pending, + last_batch_ts, + pending_ts, + if pending_recorded { + RECORD_WINDOW_MS + } else { + 0 + }, + ); + data_points.append(&mut pending); + pending_recorded = false; + } + pending_ts = ts; + pending_recorded |= recorded; + pending.extend(points); } - data_points.extend(parser.parse(&data[pos..], timestamp_ms)); - - // Spread timestamps across (prev, timestamp_ms] so - // multiple lines from one read keep distinct, - // monotonically increasing x positions instead of - // collapsing onto a single point. Aim for ~1ms per - // line, but never go below the previous batch's end - // (keeps aggregator input ordered). - last_batch_ts = spread_batch_timestamps( - &mut data_points, + last_batch_ts = spread_over_window( + &mut pending, last_batch_ts, - timestamp_ms, + pending_ts, + if pending_recorded { + RECORD_WINDOW_MS + } else { + 0 + }, ); + data_points.append(&mut pending); // Add all points in a single batch if !data_points.is_empty() { @@ -895,6 +1037,193 @@ mod tests { assert_eq!(aggregator.get_channels(), vec!["new"]); } + /// A store "created" `secs` ago holding `lines` lines received one per second + /// (line i - 2 bytes - had arrived by i+1 s), as when the plotter is opened + /// after data has been flowing + fn store_with_backlog(secs: u64, lines: u64) -> Arc { + let mut store = DataStore::new().expect("create store"); + store.backdate_for_test(Duration::from_secs(secs)); + let t0 = store.created_wall_ms(); + for i in 0..lines { + store.push_test_data(format!("{}\n", i).as_bytes()); + store.push_test_timestamp(2 * (i + 1), t0 + 1000 * (i + 1)); + } + Arc::new(store) + } + + /// Timestamps (s) of all drawn points + fn drawn_times(aggregator: &PlotterAggregator) -> Vec { + let payload = aggregator.get_chart_data(&PlotterDataRequest { + time_min_ms: None, + time_max_ms: None, + pixel_width: 800, + is_realtime: false, + }); + payload.aligned_data[0].iter().flatten().copied().collect() + } + + fn assert_one_per_second(ts: &[f64], lines: usize) { + assert_eq!(ts.len(), lines, "{ts:?}"); + for (i, t) in ts.iter().enumerate() { + let want = (i + 1) as f64; + assert!( + (t - want).abs() <= 0.05, + "line {i} at {t} s, want ~{want} s: {ts:?}" + ); + } + } + + /// Opening the plotter after data has arrived: the backlog keeps the times it + /// was received at, instead of collapsing onto the attach instant + #[test] + fn test_backlog_keeps_its_reception_times() { + let handle: SharedDataStore = Arc::new(Mutex::new(Some(store_with_backlog(11, 10)))); + let aggregator = PlotterAggregator::new(); + aggregator.set_enabled(true); + + let mut thread = PlotterThread::start(handle.clone(), aggregator.clone()); + wait_for(|| aggregator.total_points() >= 10, "10 points"); + thread.stop(); + + assert_one_per_second(&drawn_times(&aggregator), 10); + } + + /// The same when the backlog spans several reads (larger than the read cap): + /// the tail of each read takes the next record's time, not "now" + #[test] + fn test_backlog_spanning_reads_keeps_its_times() { + let handle: SharedDataStore = Arc::new(Mutex::new(Some(store_with_backlog(11, 10)))); + let aggregator = PlotterAggregator::new(); + aggregator.set_enabled(true); + + // 8-byte reads: 4 lines per read, cut mid-way through the backlog + let mut thread = PlotterThread::start_with_read_cap(handle.clone(), aggregator.clone(), 8); + wait_for(|| aggregator.total_points() >= 10, "10 points"); + thread.stop(); + + assert_one_per_second(&drawn_times(&aggregator), 10); + } + + /// Records that disagree with the live clock (wall clock ahead of it, or + /// behind the previous batch) fall back to "now": no point is lost, order + /// is kept and nothing lies in the future + #[test] + fn test_records_off_the_clock_fall_back_to_now() { + let mut store = DataStore::new().expect("create store"); + store.backdate_for_test(Duration::from_secs(2)); + let t0 = store.created_wall_ms(); + store.push_test_data(b"1\n2\n"); + store.push_test_timestamp(4, t0 + 60_000); // a minute in the future + store.push_test_data(b"3\n4\n"); + store.push_test_timestamp(8, t0 + 60_001); + let handle: SharedDataStore = Arc::new(Mutex::new(Some(Arc::new(store)))); + let aggregator = PlotterAggregator::new(); + aggregator.set_enabled(true); + + let mut thread = PlotterThread::start(handle.clone(), aggregator.clone()); + wait_for(|| aggregator.total_points() >= 4, "4 points"); + thread.stop(); + + let ts = drawn_times(&aggregator); + assert_eq!(ts.len(), 4, "{ts:?}"); + assert!(ts.windows(2).all(|w| w[0] < w[1]), "{ts:?}"); + assert!(ts.iter().all(|&t| (1.9..=3.0).contains(&t)), "{ts:?}"); + } + + /// A resume boundary inside a recorded slice: the cut lines are still + /// dropped and the times stay ordered + #[test] + fn test_resume_boundary_inside_a_recorded_slice() { + let mut store = DataStore::new().expect("create store"); + store.backdate_for_test(Duration::from_secs(3)); + let t0 = store.created_wall_ms(); + store.push_test_data(b"sin:1,cos:2\nsin:3,cos:"); + store.push_segment_start(store.total_bytes()); + store.push_test_data(b"accel:9\nsin:5,cos:6\n"); + store.push_test_timestamp(store.total_bytes(), t0 + 2000); + let handle: SharedDataStore = Arc::new(Mutex::new(Some(Arc::new(store)))); + let aggregator = PlotterAggregator::new(); + aggregator.set_enabled(true); + + let mut thread = PlotterThread::start(handle.clone(), aggregator.clone()); + wait_for(|| aggregator.total_points() >= 4, "both lines"); + thread::sleep(Duration::from_millis(50)); + thread.stop(); + + assert_eq!(aggregator.get_channels(), vec!["sin", "cos"]); + assert_eq!(aggregator.total_points(), 4); + let ts = drawn_times(&aggregator); + assert!(ts.windows(2).all(|w| w[0] < w[1]), "{ts:?}"); + assert!(ts.iter().all(|&t| (1.9..=2.0).contains(&t)), "{ts:?}"); + } + + /// The lines of one recorded ~100 ms slice are spread across it, not bunched + /// into its last few ms (a backlog would otherwise draw as a staircase) + #[test] + fn test_recorded_slice_lines_are_spread_across_its_window() { + let mut store = DataStore::new().expect("create store"); + store.backdate_for_test(Duration::from_secs(2)); + let t0 = store.created_wall_ms(); + store.push_test_data(b"1\n2\n3\n4\n5\n"); + store.push_test_timestamp(10, t0 + 1000); + let handle: SharedDataStore = Arc::new(Mutex::new(Some(Arc::new(store)))); + let aggregator = PlotterAggregator::new(); + aggregator.set_enabled(true); + + let mut thread = PlotterThread::start(handle.clone(), aggregator.clone()); + wait_for(|| aggregator.total_points() >= 5, "5 points"); + thread.stop(); + + let ts = drawn_times(&aggregator); + assert_eq!(ts, vec![0.92, 0.94, 0.96, 0.98, 1.0]); + } + + #[test] + fn test_slice_timestamp_exact_vectors() { + // (entry_wall, created_wall, last_batch_ts, now) -> ts + for (entry, created, last, now, want) in [ + (Some(1500), 1000, 0, 900, 500), // in range: the record's time + (Some(1500), 1000, 500, 900, 500), // == last is still in range + (Some(1500), 1000, 501, 900, 900), // behind the previous batch: now + (Some(1500), 1000, 0, 499, 499), // ahead of now: now + (Some(900), 1000, 0, 300, 0), // before the store: 0 + (None, 1000, 100, 300, 300), // no record: now + ] { + assert_eq!( + slice_timestamp(entry, created, last, now), + want, + "{entry:?} {created} {last} {now}" + ); + } + } + + /// The same when the store is found late (the flaky case of + /// test_batch_timestamps_are_spread under load): lines already waiting are + /// placed by the session clock, not all at the attach instant + #[test] + fn test_lines_waiting_at_attach_stay_distinct() { + let handle: SharedDataStore = Arc::new(Mutex::new(None)); + let aggregator = PlotterAggregator::new(); + aggregator.set_enabled(true); + + let store = Arc::new(DataStore::new().expect("create store")); + thread::sleep(Duration::from_millis(60)); + store.push_test_data(b"1\n2\n3\n4\n5\n6\n7\n8\n"); + *handle.lock().unwrap() = Some(store); + + let mut thread = PlotterThread::start(handle.clone(), aggregator.clone()); + wait_for(|| aggregator.total_points() >= 8, "8 points"); + thread.stop(); + + let payload = aggregator.get_chart_data(&PlotterDataRequest { + time_min_ms: None, + time_max_ms: None, + pixel_width: 800, + is_realtime: false, + }); + assert_eq!(payload.aligned_data[0].len(), 8, "points collapsed"); + } + #[test] fn test_batch_timestamps_are_spread() { // Lines read in one poll must not collapse onto a single timestamp diff --git a/src-tauri/src/serial/data_store.rs b/src-tauri/src/serial/data_store.rs index 7fb2abc..c4510a8 100644 --- a/src-tauri/src/serial/data_store.rs +++ b/src-tauri/src/serial/data_store.rs @@ -50,6 +50,11 @@ struct SerialStatusPayload { /// ObjectPool、FinishedQueue、ArchivedIndexを統合し、 /// Worker/Logger/UiNotifier スレッドのライフサイクルを管理する。 pub struct DataStore { + /// When this store (= the capture session) was created: the time origin for + /// readers that attach later, such as the plotter. `Instant` for elapsed time, + /// wall-clock ms to place `timestamp_index` entries on the same axis. + created: std::time::Instant, + created_wall_ms: u64, free_pool: Arc>, finished_list: Arc>>>, archived_index: Arc>>, @@ -108,6 +113,8 @@ impl DataStore { timestamp_index: Arc::new(RwLock::new(Vec::new())), line_index: Arc::new(RwLock::new(vec![0])), // 最初の行はオフセット0から始まる segment_starts: RwLock::new(Vec::new()), + created: std::time::Instant::now(), + created_wall_ms: wall_clock_ms(), temp_dir, worker_handle: Mutex::new(None), logger_handle: Mutex::new(None), @@ -397,6 +404,71 @@ impl DataStore { self.push_test_data_at(self.total_bytes(), data); } + /// 受信データ全体を、各行の先頭にタイムスタンプと区切りを付けて書き出す(issue #4) + /// + /// データ本体は受信したバイトのまま(CR/LF も含め無加工)で、行頭(LF の直後)に + /// `format(行頭オフセットの時刻)` と `separator` を挿入するだけ。データは 1 MiB + /// ずつ順に読み、行頭は数千行ずつ索引から取るので、巨大な取り込みでもメモリは + /// 一定。呼び出し時点の総バイト数までを書き、その後に届いた分は含めない。 + /// 戻り値は書き出したデータのバイト数(挿入した文字は含まない)。 + pub fn write_timestamped( + &self, + out: &mut W, + separator: &str, + format: impl Fn(Option) -> String, + ) -> Result { + const CHUNK: u64 = 1024 * 1024; + const LINE_BATCH: u32 = 4096; + + let total = self.total_bytes(); + let mut starts: VecDeque = VecDeque::new(); + let mut next_line = 0u64; + let mut lines_done = false; + let mut offset = 0u64; + + while offset < total { + let len = CHUNK.min(total - offset) as u32; + let data = self.get_data(offset, len)?; + if data.is_empty() { + return Err(format!("No data at offset {}", offset)); + } + let end = offset + data.len() as u64; + let mut pos = offset; + loop { + if starts.is_empty() && !lines_done { + let batch = self.get_line_offsets(next_line, LINE_BATCH); + lines_done = batch.is_empty(); + next_line += batch.len() as u64; + starts.extend(batch.into_iter().map(|(s, _)| s)); + } + match starts.front().copied() { + // 先頭の行が書き出し済みの位置より前になることはないが、念のため捨てる + Some(ls) if ls < pos => { + starts.pop_front(); + } + Some(ls) if ls < end => { + let a = (pos - offset) as usize; + let b = (ls - offset) as usize; + out.write_all(&data[a..b]).map_err(|e| e.to_string())?; + let stamp = format(self.get_timestamp_for_offset(ls)); + out.write_all(stamp.as_bytes()).map_err(|e| e.to_string())?; + out.write_all(separator.as_bytes()) + .map_err(|e| e.to_string())?; + pos = ls; + starts.pop_front(); + } + _ => { + let a = (pos - offset) as usize; + out.write_all(&data[a..]).map_err(|e| e.to_string())?; + break; + } + } + } + offset = end; + } + Ok(total) + } + /// 受信セグメントの開始オフセットのうち `[from, to)` に入るもの(昇順) pub fn segment_starts_in(&self, from: u64, to: u64) -> Vec { self.segment_starts @@ -411,6 +483,57 @@ impl DataStore { .unwrap_or_default() } + /// このストア(取り込みセッション)を作った時刻(経過時間用) + pub fn created_instant(&self) -> std::time::Instant { + self.created + } + + /// このストアを作った時刻(Unix ms、`timestamp_index` と同じ時計) + pub fn created_wall_ms(&self) -> u64 { + self.created_wall_ms + } + + /// `timestamp_index` のうち累積バイト数が `(from, to]` に入る記録(昇順)。 + /// 記録 (b, t) は「時刻 t までに b バイト届いていた」を意味する + pub fn timestamps_in(&self, from: u64, to: u64) -> Vec { + self.timestamp_index + .read() + .map(|index| { + let lo = index.partition_point(|e| e.cumulative_bytes <= from); + let hi = index.partition_point(|e| e.cumulative_bytes <= to); + index[lo..hi.max(lo)].to_vec() + }) + .unwrap_or_default() + } + + /// 累積バイト数が `offset` 以上になった最初の記録(`offset` 番目までのバイトが + /// 届いていた最初の時刻)。まだ記録がなければ None + pub fn timestamp_at_or_after(&self, offset: u64) -> Option { + let index = self.timestamp_index.read().ok()?; + let i = index.partition_point(|e| e.cumulative_bytes < offset); + index.get(i).cloned() + } + + /// テスト用: このストアを `by` だけ前に作ったことにする(過去の受信を、 + /// 実際に過去の時刻として記録できるように) + #[cfg(test)] + pub fn backdate_for_test(&mut self, by: std::time::Duration) { + self.created = self.created.checked_sub(by).expect("backdate"); + self.created_wall_ms -= by.as_millis() as u64; + } + + /// テスト用: 「時刻 `wall_ms` に累積 `cumulative_bytes` バイト」を記録する + /// (`record_timestamp` は現在時刻しか記録できないため) + #[cfg(test)] + pub fn push_test_timestamp(&self, cumulative_bytes: u64, wall_ms: u64) { + if let Ok(mut index) = self.timestamp_index.write() { + index.push(ByteTimestamp { + timestamp: wall_ms, + cumulative_bytes, + }); + } + } + /// テスト用: 受信の開始・再開位置を登録する(`start_reception` の代わり) #[cfg(test)] pub fn push_segment_start(&self, offset: u64) { @@ -702,6 +825,15 @@ fn is_process_running(pid: u32) -> bool { } } +/// Current wall-clock time in Unix ms (the clock of `timestamp_index`) +fn wall_clock_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + #[cfg(test)] mod tests { use super::*; @@ -735,6 +867,8 @@ mod tests { timestamp_index: Arc::new(RwLock::new(Vec::new())), line_index: Arc::new(RwLock::new(vec![0])), segment_starts: RwLock::new(Vec::new()), + created: std::time::Instant::now(), + created_wall_ms: wall_clock_ms(), temp_dir, worker_handle: Mutex::new(None), logger_handle: Mutex::new(None), @@ -743,6 +877,74 @@ mod tests { } } + /// `timestamps_in` は (from, to] の記録、`timestamp_at_or_after` は境界を含む次の記録 + #[test] + fn timestamp_lookups_respect_their_bounds() { + let store = create_test_data_store(); + assert!(store.timestamps_in(0, 100).is_empty()); + assert!(store.timestamp_at_or_after(0).is_none()); + store.push_test_timestamp(10, 1000); + store.push_test_timestamp(20, 2000); + store.push_test_timestamp(30, 3000); + + let bytes = + |v: Vec| v.iter().map(|e| e.cumulative_bytes).collect::>(); + assert_eq!(bytes(store.timestamps_in(10, 30)), vec![20, 30]); // from excluded, to included + assert_eq!(bytes(store.timestamps_in(0, 9)), Vec::::new()); + assert_eq!(bytes(store.timestamps_in(30, 10)), Vec::::new()); // from > to + assert_eq!( + store.timestamp_at_or_after(20).map(|e| e.timestamp), + Some(2000) + ); + assert_eq!( + store.timestamp_at_or_after(21).map(|e| e.timestamp), + Some(3000) + ); + assert!(store.timestamp_at_or_after(31).is_none()); + } + + /// タイムスタンプ付き書き出し(issue #4): 各行頭に時刻と区切りが入り、データは無加工 + #[test] + fn write_timestamped_prefixes_every_line_and_keeps_bytes() { + let store = create_test_data_store(); + let data = b"ab\r\ncd\nlast"; + store.push_test_data(data); + { + // 行頭: 0, 4 ("cd"), 7 ("last") + let mut idx = store.line_index.write().unwrap(); + idx.extend([4, 7]); + } + let fmt = |ts: Option| format!("[{}]", ts.map_or("-".to_string(), |t| t.to_string())); + { + let mut ts = store.timestamp_index.write().unwrap(); + ts.push(ByteTimestamp { + cumulative_bytes: 4, + timestamp: 1000, + }); + } + + let mut out = Vec::new(); + let n = store.write_timestamped(&mut out, ",", fmt).unwrap(); + + assert_eq!(n, data.len() as u64); + assert_eq!(out, b"[-],ab\r\n[1000],cd\n[1000],last"); + } + + /// 末尾が LF で終わるとき、まだ中身のない最終行に時刻だけが付くことはない + #[test] + fn write_timestamped_adds_nothing_after_a_trailing_newline() { + let store = create_test_data_store(); + store.push_test_data(b"x\n"); + store.line_index.write().unwrap().push(2); + + let mut out = Vec::new(); + store + .write_timestamped(&mut out, "\t", |_| "T".to_string()) + .unwrap(); + + assert_eq!(out, b"T\tx\n"); + } + /// archived_indexのみからデータを取得できることを確認 #[test] fn test_get_data_from_archived_only() { diff --git a/src-tauri/src/serial/mod.rs b/src-tauri/src/serial/mod.rs index 4a22993..e99b9ce 100644 --- a/src-tauri/src/serial/mod.rs +++ b/src-tauri/src/serial/mod.rs @@ -221,6 +221,8 @@ pub struct DisplayRow { offset: u64, hex: String, ascii: String, + /// Reception time of the row's first byte (`HH:MM:SS.d`) when requested + timestamp: Option, } /// Payload for get_display_rows command @@ -257,6 +259,23 @@ fn bytes_to_hex(data: &[u8]) -> String { .join(" ") } +/// Hex rows of `BYTES_PER_ROW` bytes as in the Hex view, each preceded by the +/// reception time of its first byte (`data` starts at offset 0), one row per line +fn hex_rows_with_timestamps( + data: &[u8], + separator: &str, + timestamp_at: impl Fn(u64) -> String, +) -> String { + let mut out = String::with_capacity(data.len() / BYTES_PER_ROW * 72 + 72); + for (i, row) in data.chunks(BYTES_PER_ROW).enumerate() { + out.push_str(×tamp_at((i * BYTES_PER_ROW) as u64)); + out.push_str(separator); + out.push_str(&bytes_to_hex(row)); + out.push_str("\r\n"); + } + out +} + /// Convert bytes to ASCII string fn bytes_to_ascii(data: &[u8]) -> String { data.iter().map(|&b| byte_to_ascii(b)).collect() @@ -267,7 +286,9 @@ pub fn get_display_rows( state: State<'_, SerialState>, start_row: u64, row_count: u32, + show_timestamp: Option, ) -> Result { + let show_timestamp = show_timestamp.unwrap_or(false); let store_guard = state.data_store.lock().map_err(|e| e.to_string())?; if let Some(ref data_store) = *store_guard { @@ -332,6 +353,9 @@ pub fn get_display_rows( offset: row_offset, hex: bytes_to_hex(row_data), ascii: bytes_to_ascii(row_data), + timestamp: show_timestamp.then(|| { + format_line_timestamp(data_store.get_timestamp_for_offset(row_offset)) + }), }); } } @@ -357,6 +381,35 @@ pub struct AsciiLine { pub struct AsciiLinesPayload { lines: Vec, total_lines: u64, + /// Line index of `lines[0]` actually served. A request past the end is clamped to + /// the last `line_count` lines, so a viewer that asks for "the tail" gets it + /// atomically even while the line count grows (issue #3). + start_line: u64, + /// Whether the served range runs to the end of the data. Not derivable from + /// `lines.len()`: after a trailing LF the line index holds an empty last line, + /// which is not sent. + reaches_end: bool, +} + +/// Clamp a requested start line so that a request past the end serves the last +/// `line_count` lines instead of nothing. +fn clamp_start_line(start_line: u64, line_count: u32, total_lines: u64) -> u64 { + start_line.min(total_lines.saturating_sub(line_count as u64)) +} + +/// Timestamp text for a line, the same everywhere (view, Copy, Save): local +/// `HH:MM:SS.d`. Timestamps are recorded about every 100 ms, so tenths are the real +/// precision; lines received before the first record show `--:--:--.0`. +pub(crate) fn format_line_timestamp(ts_millis: Option) -> String { + ts_millis + .and_then(|ms| { + Local + .timestamp_millis_opt(ms as i64) + .single() + .map(|dt| (ms, dt)) + }) + .map(|(ms, dt)| format!("{}.{}", dt.format("%H:%M:%S"), (ms % 1000) / 100)) + .unwrap_or_else(|| "--:--:--.0".to_string()) } #[derive(Clone, serde::Serialize)] @@ -395,6 +448,8 @@ pub fn get_ascii_lines( return Ok(AsciiLinesPayload { lines: vec![], total_lines: 0, + start_line: 0, + reaches_end: true, }); } @@ -407,16 +462,23 @@ pub fn get_ascii_lines( return Ok(AsciiLinesPayload { lines: vec![], total_lines: 0, + start_line: 0, + reaches_end: true, }); } + let start_line = clamp_start_line(start_line, line_count, total_lines); + // Get line offsets for requested range let line_offsets = data_store.get_line_offsets(start_line, line_count); + let reaches_end = start_line + line_offsets.len() as u64 >= total_lines; if line_offsets.is_empty() { return Ok(AsciiLinesPayload { lines: vec![], total_lines, + start_line, + reaches_end, }); } @@ -431,6 +493,8 @@ pub fn get_ascii_lines( return Ok(AsciiLinesPayload { lines: vec![], total_lines, + start_line, + reaches_end, }); } @@ -442,6 +506,8 @@ pub fn get_ascii_lines( return Ok(AsciiLinesPayload { lines: vec![], total_lines, + start_line, + reaches_end, }); } }; @@ -458,23 +524,9 @@ pub fn get_ascii_lines( let text = bytes_to_ascii(line_data); // Get timestamp from timestamp_index using binary search - let timestamp = if show_timestamp { - if let Some(ts_millis) = data_store.get_timestamp_for_offset(line_start) { - // Use chrono to convert UTC timestamp (millis) to Local time - // timestamp_millis_opt handles the conversion safely - if let Some(dt) = Local.timestamp_millis_opt(ts_millis as i64).single() { - let time_str = dt.format("%H:%M:%S").to_string(); - let d = (ts_millis % 1000) / 100; - Some(format!("{}.{}", time_str, d)) - } else { - Some("--:--:--.0".to_string()) - } - } else { - Some("--:--:--.0".to_string()) - } - } else { - None - }; + let timestamp = show_timestamp.then(|| { + format_line_timestamp(data_store.get_timestamp_for_offset(line_start)) + }); lines.push(AsciiLine { offset: line_start, @@ -484,11 +536,18 @@ pub fn get_ascii_lines( } } - Ok(AsciiLinesPayload { lines, total_lines }) + Ok(AsciiLinesPayload { + lines, + total_lines, + start_line, + reaches_end, + }) } else { Ok(AsciiLinesPayload { lines: vec![], total_lines: 0, + start_line: 0, + reaches_end: true, }) } } @@ -498,6 +557,18 @@ pub fn list_ports() -> Vec { enumerate_ports() } +/// List entry for a USB port: "Product (COM5)". +/// +/// On Windows the product string is the device's friendly name, which already ends +/// in " (COM5)"; that suffix is dropped so the entry does not read +/// "USB Serial Port (COM5) (COM5)". +fn usb_port_label(product: Option<&str>, port_name: &str) -> String { + let product = product.unwrap_or("Unknown Device"); + let suffix = format!(" ({port_name})"); + let product = product.strip_suffix(suffix.as_str()).unwrap_or(product); + format!("{product} ({port_name})") +} + /// 利用可能なポートの列挙(`list_ports` コマンドの実体) /// /// AI ブリッジの `ports` メソッドが GUI と同じ一覧を返せるよう、コマンド属性の @@ -510,8 +581,7 @@ pub(crate) fn enumerate_ports() -> Vec { // Include friendly name for USB devices, or just the port name match &p.port_type { serialport::SerialPortType::UsbPort(info) => { - let product = info.product.as_deref().unwrap_or("Unknown Device"); - format!("{} ({})", product, p.port_name) + usb_port_label(info.product.as_deref(), &p.port_name) } serialport::SerialPortType::PciPort => { format!("PCI Device ({})", p.port_name) @@ -565,6 +635,47 @@ pub fn export_log(state: State<'_, SerialState>, path: String) -> Result Result<(), String> { + match separator { + " > " | " " | "," | "\t" => Ok(()), + other => Err(format!("Unsupported timestamp separator: {:?}", other)), + } +} + +/// Save the received data as text with a timestamp at the start of every line +/// (issue #4: Save while Timestamp is ON). The data bytes are written unchanged; +/// `export_log` remains the raw-bytes export. +#[tauri::command] +pub fn export_text_with_timestamps( + state: State<'_, SerialState>, + path: String, + separator: String, +) -> Result { + use std::io::{BufWriter, Write}; + + validate_timestamp_separator(&separator)?; + let store_guard = state.data_store.lock().map_err(|e| e.to_string())?; + let Some(ref data_store) = *store_guard else { + return Err("No data available".to_string()); + }; + if data_store.total_bytes() == 0 { + return Err("No data to export".to_string()); + } + let file = std::fs::File::create(&path) + .map_err(|e| format!("Failed to create file '{}': {:?}", path, e))?; + let mut out = BufWriter::new(file); + let total = data_store.write_timestamped(&mut out, &separator, format_line_timestamp)?; + out.flush() + .map_err(|e| format!("Failed to write data: {:?}", e))?; + log::info!( + "[export_text_with_timestamps] Exported {} bytes to {}", + total, + path + ); + Ok(total) +} + #[tauri::command] pub fn clear_data(state: State<'_, SerialState>, app: AppHandle) -> Result<(), String> { let mut store_guard = state.data_store.lock().map_err(|e| e.to_string())?; @@ -598,7 +709,11 @@ pub fn clear_data(state: State<'_, SerialState>, app: AppHandle) -> Result<(), S } #[tauri::command] -pub fn get_clipboard_text(state: State<'_, SerialState>, mode: String) -> Result { +pub fn get_clipboard_text( + state: State<'_, SerialState>, + mode: String, + timestamp_separator: Option, +) -> Result { let store_guard = state.data_store.lock().map_err(|e| e.to_string())?; if let Some(ref data_store) = *store_guard { @@ -615,6 +730,22 @@ pub fn get_clipboard_text(state: State<'_, SerialState>, mode: String) -> Result let to_read = total.min(u32::MAX as u64) as u32; let data = data_store.get_data(0, to_read)?; + // Timestamp ON in the Hex view: the rows as shown, each with its time + if let (Some(separator), true) = (timestamp_separator.as_deref(), mode == "hex") { + validate_timestamp_separator(separator)?; + return Ok(hex_rows_with_timestamps(&data, separator, |offset| { + format_line_timestamp(data_store.get_timestamp_for_offset(offset)) + })); + } + + // Timestamp ON in the ASCII view (issue #4): the same text as Save + if let Some(separator) = timestamp_separator.as_deref() { + validate_timestamp_separator(separator)?; + let mut out = Vec::new(); + data_store.write_timestamped(&mut out, separator, format_line_timestamp)?; + return Ok(String::from_utf8_lossy(&out).to_string()); + } + if mode == "hex" { // Hex mode: space-separated hex string Ok(bytes_to_hex(&data)) @@ -631,6 +762,27 @@ pub fn get_clipboard_text(state: State<'_, SerialState>, mode: String) -> Result mod tests { use super::*; + #[test] + fn clamp_start_line_serves_the_tail_for_requests_past_the_end() { + // In range: unchanged + assert_eq!(clamp_start_line(10, 50, 1000), 10); + assert_eq!(clamp_start_line(950, 50, 1000), 950); + // Past the end (a viewer asking for "the tail"): the last line_count lines + assert_eq!(clamp_start_line(u64::MAX, 50, 1000), 950); + assert_eq!(clamp_start_line(990, 50, 1000), 950); + // Fewer lines than requested: everything from line 0 + assert_eq!(clamp_start_line(u64::MAX, 50, 20), 0); + assert_eq!(clamp_start_line(0, 50, 0), 0); + } + + #[test] + fn line_timestamp_has_tenths_and_a_fallback() { + assert_eq!(format_line_timestamp(None), "--:--:--.0"); + let s = format_line_timestamp(Some(1_700_000_000_987)); + assert_eq!(s.len(), "HH:MM:SS.d".len()); + assert!(s.ends_with(".9"), "{s}"); + } + #[test] fn test_extract_port_path() { // Windows friendly names @@ -652,6 +804,39 @@ mod tests { assert_eq!(extract_port_path(""), ""); } + #[test] + fn hex_rows_with_timestamps_puts_each_row_on_its_own_line() { + let data: Vec = (0u8..20).collect(); + let text = hex_rows_with_timestamps(&data, " > ", |offset| format!("t{offset}")); + assert_eq!( + text, + "t0 > 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F\r\nt16 > 10 11 12 13\r\n" + ); + assert_eq!(hex_rows_with_timestamps(&[], ",", |_| String::new()), ""); + } + + #[test] + fn usb_port_label_drops_the_duplicated_port_suffix() { + assert_eq!( + usb_port_label(Some("USB Serial Port (COM5)"), "COM5"), + "USB Serial Port (COM5)" + ); + assert_eq!( + usb_port_label(Some("CP2102 USB to UART"), "/dev/ttyUSB0"), + "CP2102 USB to UART (/dev/ttyUSB0)" + ); + assert_eq!(usb_port_label(None, "COM7"), "Unknown Device (COM7)"); + // Another port's number in the name is kept + assert_eq!( + usb_port_label(Some("Bridge (COM4)"), "COM5"), + "Bridge (COM4) (COM5)" + ); + assert_eq!( + extract_port_path(&usb_port_label(Some("X (COM5)"), "COM5")), + "COM5" + ); + } + #[test] fn test_byte_to_ascii_printable() { // Printable ASCII characters (0x20-0x7e) diff --git a/src-tauri/src/serial/port.rs b/src-tauri/src/serial/port.rs index b22c168..1356da9 100644 --- a/src-tauri/src/serial/port.rs +++ b/src-tauri/src/serial/port.rs @@ -76,14 +76,17 @@ impl SerialPort { .open() .map_err(|e| format!("Failed to open port {}: {:?}", port_name, e))?; - // Enable/Disable DTR + // Opening leaves DTR and RTS off (Windows DCB). DTR must be set BEFORE RTS: + // on ESP32-style auto-reset circuits the transient (RTS on, DTR off) pulls EN + // low and resets the board, while (DTR on, RTS off) only holds IO0 low. + // (serialport's dtr_on_open does the same write but ignores its error.) if let Err(e) = port.write_data_terminal_ready(config.dtr) { warn!("[SerialPort] Failed to set DTR: {:?}", e); } else { info!("[SerialPort] DTR set to {}", config.dtr); } - // Enable/Disable RTS + // Enable/Disable RTS (after DTR, see above) if let Err(e) = port.write_request_to_send(config.rts) { warn!("[SerialPort] Failed to set RTS: {:?}", e); } else { diff --git a/src-tauri/src/serial/worker_thread.rs b/src-tauri/src/serial/worker_thread.rs index e794fa7..00cef4e 100644 --- a/src-tauri/src/serial/worker_thread.rs +++ b/src-tauri/src/serial/worker_thread.rs @@ -62,38 +62,62 @@ impl Drop for TimerResolution { } } -/// Record line offsets (LF positions) in the given data -fn record_line_offsets(line_index: &RwLock>, data: &[u8], global_offset: u64) { +/// Record the line starts in `data` (the bytes at `global_offset`). +/// +/// LF, CR and CRLF each end a line, as in the C# version: CRLF is one break, LF CR +/// and CR CR are two. Whether a CR is followed by LF is only known at the next byte, +/// so a CR that ends `data` stays pending in `pending_cr` and is resolved by the +/// next call (the index never has to shrink). Calls must be for contiguous data. +fn record_line_offsets( + line_index: &RwLock>, + data: &[u8], + global_offset: u64, + pending_cr: &mut bool, +) { if let Ok(mut index) = line_index.write() { + let mut push = |offset: u64| { + // 重複を避ける + if index.last().is_none_or(|&last| last < offset) { + index.push(offset); + } + }; for (i, &byte) in data.iter().enumerate() { - // LF (\n) を改行として検出 + let offset = global_offset + i as u64; + if *pending_cr && byte != b'\n' { + // A lone CR: the line ended before this byte + push(offset); + } + *pending_cr = byte == b'\r'; if byte == b'\n' { - let next_line_offset = global_offset + i as u64 + 1; - // 重複を避ける - if index.last().is_none_or(|&last| last < next_line_offset) { - index.push(next_line_offset); - } + push(offset + 1); } } } } +/// 受信データの行位置を line_index に記録する(CR 単独の改行を判定するため、チャンクを +/// またいで「直前が CR だったか」を持つ)。 +/// /// 再接続で同じストアに追記を再開したとき、切断で途切れた行と再開後の最初の行が -/// 1 行に繋がって見えないよう、再開位置を行頭として 1 度だけ記録する。 +/// 1 行に繋がって見えないよう、再開位置を行頭として 1 度だけ記録する。切断直前の +/// バイトが CR で保留のままだった場合も、ここで行頭が入る。 /// 受信バイトそのものは変えない(line_index は表示用の索引)。 -struct ResumeBoundary(Option); +struct LineIndexer { + resume: Option, + pending_cr: bool, +} -impl ResumeBoundary { +impl LineIndexer { fn new(line_index: &RwLock>, start_offset: u64) -> Self { // 直前のバイトが LF なら start_offset は既に行頭として記録されている let already_line_start = line_index .read() .map(|index| index.last().is_some_and(|&last| last >= start_offset)) .unwrap_or(true); - if start_offset == 0 || already_line_start { - Self(None) - } else { - Self(Some(start_offset)) + let resume = (start_offset != 0 && !already_line_start).then_some(start_offset); + Self { + resume, + pending_cr: false, } } @@ -103,14 +127,19 @@ impl ResumeBoundary { if !chunk.has_data() { return; } - if let Some(boundary) = self.0.take() { + if let Some(boundary) = self.resume.take() { if let Ok(mut index) = line_index.write() { if index.last().is_none_or(|&last| last < boundary) { index.push(boundary); } } } - record_line_offsets(line_index, chunk.data(), chunk.global_offset()); + record_line_offsets( + line_index, + chunk.data(), + chunk.global_offset(), + &mut self.pending_cr, + ); } } @@ -149,7 +178,7 @@ pub fn spawn_worker_thread( // グローバルオフセット。新しいストアなら 0、自動再接続で同じストアに // 追記を再開したときは前回までの総バイト数から続ける let mut global_offset = start_offset; - let mut boundary = ResumeBoundary::new(&line_index, start_offset); + let mut boundary = LineIndexer::new(&line_index, start_offset); let mut loop_count = 0u64; let mut last_status_log = Instant::now(); // 受信が止まる時間の診断(5 秒窓の最大値)。フロー制御の無い UART では @@ -613,7 +642,7 @@ mod tests { #[test] fn test_resume_boundary_splits_cut_line() { let index = RwLock::new(vec![0, 4]); // "abc\nde" の後で切断(total 6) - let mut boundary = ResumeBoundary::new(&index, 6); + let mut boundary = LineIndexer::new(&index, 6); boundary.record(&index, &chunk_at(6, b"")); // 空チャンクでは入れない assert_eq!(*index.read().unwrap(), vec![0, 4]); boundary.record(&index, &chunk_at(6, b"xy\n")); @@ -625,16 +654,28 @@ mod tests { #[test] fn test_resume_boundary_not_needed_after_newline() { let index = RwLock::new(vec![0, 4]); // "abc\n" の後で切断(total 4) - let mut boundary = ResumeBoundary::new(&index, 4); + let mut boundary = LineIndexer::new(&index, 4); boundary.record(&index, &chunk_at(4, b"x\n")); assert_eq!(*index.read().unwrap(), vec![0, 4, 6]); } + /// 切断直前が CR(保留のまま)でも、再開位置が行頭として入る + #[test] + fn test_resume_after_pending_cr() { + let index = RwLock::new(vec![0]); + let mut first = LineIndexer::new(&index, 0); + first.record(&index, &chunk_at(0, b"ab\r")); // CR pending when the worker stops + assert_eq!(*index.read().unwrap(), vec![0]); + let mut resumed = LineIndexer::new(&index, 3); + resumed.record(&index, &chunk_at(3, b"cd\r\n")); + assert_eq!(*index.read().unwrap(), vec![0, 3, 7]); + } + /// 新しいストア(start_offset 0)では何もしない #[test] fn test_resume_boundary_noop_for_new_store() { let index = RwLock::new(vec![0]); - let mut boundary = ResumeBoundary::new(&index, 0); + let mut boundary = LineIndexer::new(&index, 0); boundary.record(&index, &chunk_at(0, b"a\nb")); assert_eq!(*index.read().unwrap(), vec![0, 2]); } @@ -645,7 +686,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"Hello\nWorld\n"; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // Initial [0] + 2 newlines: [0, 6, 12] @@ -661,7 +702,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"Line\n"; - record_line_offsets(&line_index, data, 100); + record_line_offsets(&line_index, data, 100, &mut false); let index = line_index.read().unwrap(); assert_eq!(index.len(), 2); @@ -675,8 +716,8 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"A\n"; - record_line_offsets(&line_index, data, 0); - record_line_offsets(&line_index, data, 0); // Same data again + record_line_offsets(&line_index, data, 0, &mut false); + record_line_offsets(&line_index, data, 0, &mut false); // Same data again let index = line_index.read().unwrap(); // Should not duplicate: [0, 2] @@ -689,7 +730,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b""; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // No change: [0] @@ -702,7 +743,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"NoNewlineHere"; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // No change: [0] @@ -715,7 +756,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"\n\n\n"; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // [0, 1, 2, 3] @@ -726,17 +767,39 @@ mod tests { assert_eq!(index[3], 3); } - /// record_line_offsets should only detect LF, not CR - #[test] - fn test_record_line_offsets_ignores_cr() { - let line_index = Arc::new(RwLock::new(vec![0u64])); - let data = b"Hello\rWorld"; // CR only, no LF + /// Line starts for `parts` fed as consecutive chunks + fn index_of(parts: &[&[u8]]) -> Vec { + let line_index = RwLock::new(vec![0u64]); + let mut pending = false; + let mut offset = 0; + for part in parts { + record_line_offsets(&line_index, part, offset, &mut pending); + offset += part.len() as u64; + } + line_index.into_inner().unwrap() + } - record_line_offsets(&line_index, data, 0); + /// A lone CR ends a line (C# version), CRLF is one break, LF CR and CR CR are two + #[test] + fn test_record_line_offsets_cr_rules() { + assert_eq!(index_of(&[b"Hello\rWorld"]), vec![0, 6]); + assert_eq!(index_of(&[b"a\r\nb"]), vec![0, 3]); + assert_eq!(index_of(&[b"a\n\rb"]), vec![0, 2, 3]); + assert_eq!(index_of(&[b"a\r\rb"]), vec![0, 2, 3]); + assert_eq!(index_of(&[b"a\rb\rc\r"]), vec![0, 2, 4]); // last CR still pending + } - let index = line_index.read().unwrap(); - // No change: [0] - CR is not treated as line boundary - assert_eq!(index.len(), 1); + /// A CR at the end of a chunk is decided by the next chunk's first byte + #[test] + fn test_record_line_offsets_cr_across_chunks() { + assert_eq!(index_of(&[b"a\r", b"\nb"]), vec![0, 3]); + assert_eq!(index_of(&[b"a\r", b"b"]), vec![0, 2]); + assert_eq!(index_of(&[b"a\r", b"", b"b"]), vec![0, 2]); + assert_eq!(index_of(&[b"a\r", b"\r", b"\n"]), vec![0, 2, 4]); + // Byte by byte gives the same index as one chunk + let data = b"x\ry\r\nz\n\r\rw"; + let bytes: Vec<&[u8]> = data.chunks(1).collect(); + assert_eq!(index_of(&bytes), index_of(&[data])); } /// record_line_offsets should detect LF in CRLF sequences @@ -745,7 +808,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"Hello\r\nWorld\r\n"; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // [0, 7, 14] - LF at positions 6 and 13 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index eee46e3..5cbc3ff 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "serial-monitor-essential", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.2", "identifier": "io.github.771-8bit.serial-monitor-essential", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index 237d031..20522e8 100644 --- a/src/App.css +++ b/src/App.css @@ -1,11 +1,92 @@ +/* + * Theme (issue #6): semantic color tokens. is set + * from the Theme setting (Dark / Light / System); `color-scheme` makes the native + * widgets (select popups, checkboxes, scrollbars) match instead of following the OS. + * Accent, status and chart-series colors are shared by both themes. + */ +:root, +:root[data-theme='dark'] { + color-scheme: dark; + --bg-app: #242424; + --bg-panel: #1a1a1a; + --bg-viewer: #1e1e1e; + --bg-elevated: #252526; + --bg-input: #2a2a2a; + --bg-sunken: #0f0f0f; + --bg-hover: #3a3a3a; + --bg-active: #444; + --bg-overlay: rgba(30, 30, 30, 0.9); + --border: #3c3c3c; + --border-strong: #5a5a5a; + --text: #e8e8e8; + --text-muted: #bbbbbb; + --text-dim: #888; + --info: #8cf; + --timestamp: #6a9955; + --hex-bytes: #bfa; + --hex-ascii: #eca; + --hex-mode: #ff7043; + --syntax-offset: #569cd6; + --syntax-string: #ce9178; + --syntax-fn: #dcdcaa; + --success-text: #8ce99a; + --success-bg: #1d4620; + --danger-text: #ffa8a8; + --danger-bg: #4a1d1d; + --warn-text: #f0a830; + --scrollbar-thumb: #424242; + --scrollbar-thumb-hover: #5c5c5c; + /* Chart canvas (read from JS) */ + --chart-axis: #888; + --chart-grid: #333; + --chart-tick: #444; + --chart-label: #9cdcfe; +} + +:root[data-theme='light'] { + color-scheme: light; + --bg-app: #ececec; + --bg-panel: #ffffff; + --bg-viewer: #ffffff; + --bg-elevated: #f3f3f3; + --bg-input: #ffffff; + --bg-sunken: #ffffff; + --bg-hover: #e4e4e4; + --bg-active: #d4d4d4; + --bg-overlay: rgba(255, 255, 255, 0.94); + --border: #c8c8c8; + --border-strong: #9a9a9a; + --text: #1f1f1f; + --text-muted: #444; + --text-dim: #6b6b6b; + --info: #0b62b8; + --timestamp: #2e7d32; + --hex-bytes: #1a7f37; + --hex-ascii: #9a5b00; + --hex-mode: #d84315; + --syntax-offset: #0451a5; + --syntax-string: #a31515; + --syntax-fn: #795e26; + --success-text: #1a7f37; + --success-bg: #e3f4e6; + --danger-text: #c62828; + --danger-bg: #fdeaea; + --warn-text: #a35400; + --scrollbar-thumb: #c1c1c1; + --scrollbar-thumb-hover: #a8a8a8; + --chart-axis: #555; + --chart-grid: #e2e2e2; + --chart-tick: #c8c8c8; + --chart-label: #0451a5; +} + :root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color: var(--text); + background-color: var(--bg-app); font-synthesis: none; text-rendering: optimizeLegibility; @@ -19,7 +100,7 @@ body { width: 100vw; height: 100vh; overflow: hidden; - color: #f6f6f6; + color: var(--text); user-select: none; /* Disable text selection */ -webkit-user-select: none; @@ -53,8 +134,8 @@ textarea { .settings-panel, .send-panel, .receive-panel { - background: #1a1a1a; - border: 1px solid #333; + background: var(--bg-panel); + border: 1px solid var(--border); border-radius: 4px; padding: 8px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); @@ -62,7 +143,7 @@ textarea { .panel-header { font-size: 12px; - color: #aaa; + color: var(--text-muted); margin-bottom: 6px; font-weight: 400; } @@ -80,13 +161,25 @@ textarea { gap: 8px; } +/* The port list takes whatever width the controls on its right leave; its width never + depends on the device names. The other controls keep their own fixed sizes. */ +.settings-row > * { + flex: none; +} + +.settings-row > .port-select { + flex: 1 1 0; + width: 0; + min-width: 120px; +} + .settings-advanced { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding-top: 4px; - border-top: 1px dashed #444; + border-top: 1px dashed var(--border); } .advanced-group { @@ -94,7 +187,7 @@ textarea { align-items: center; gap: 4px; font-size: 12px; - color: #ccc; + color: var(--text-muted); } .checkbox-group { @@ -113,9 +206,9 @@ select, input[type='text'], input[type='number'] { padding: 4px 8px; - border: 1px solid #444; - background-color: #2a2a2a; - color: #eee; + border: 1px solid var(--border); + background-color: var(--bg-input); + color: var(--text); border-radius: 3px; font-size: 13px; height: 28px; @@ -129,22 +222,22 @@ input[type='checkbox'] { button { padding: 0 12px; height: 28px; - border: 1px solid #444; + border: 1px solid var(--border); border-radius: 3px; - background-color: #2a2a2a; - color: #eee; + background-color: var(--bg-input); + color: var(--text); cursor: pointer; font-size: 13px; transition: all 0.1s; } button:hover { - background-color: #3a3a3a; - border-color: #666; + background-color: var(--bg-hover); + border-color: var(--border-strong); } button:active { - background-color: #444; + background-color: var(--bg-active); } button:disabled { @@ -153,7 +246,8 @@ button:disabled { } .connect-button { - min-width: 80px; + /* Fixed: Connect / Disconnect / Cancel must not resize the port list */ + width: 100px; } .connect-button.connected { @@ -184,16 +278,30 @@ button:disabled { gap: 8px; } -.send-input { +.send-input-column { flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.send-error { + color: var(--danger-text); + font-size: 12px; +} + +.send-input { height: 80px; resize: none; - border: 1px solid #444; - background-color: #0f0f0f; - color: #eee; + border: 1px solid var(--border); + background-color: var(--bg-sunken); + color: var(--text); border-radius: 3px; font-family: 'Consolas', monospace; - font-size: 13px; + /* Larger than the receive view (13px) so typos in commands are easy to spot, + as in the C# version (16pt send vs 12pt receive) */ + font-size: 17px; padding: 6px; } @@ -222,7 +330,7 @@ button:disabled { flex-direction: column; gap: 4px; font-size: 12px; - color: #ccc; + color: var(--text-muted); } .send-options label { @@ -251,22 +359,22 @@ button:disabled { height: 100%; display: flex; flex-direction: column; - color: #eee; + color: var(--text); /* Ensure text is light */ } /* Ensure HexViewer content is readable in dark mode */ .hex-offset { - color: #888; + color: var(--text-dim); } .hex-bytes { - color: #bfa; + color: var(--hex-bytes); /* Slightly green for bytes */ } .hex-ascii { - color: #eca; + color: var(--hex-ascii); /* Slightly orange for ascii */ } @@ -276,10 +384,10 @@ button:disabled { } .hex-footer { - border-top: 1px solid #333; + border-top: 1px solid var(--border); padding-top: 4px; font-size: 11px; - color: #888; + color: var(--text-dim); } .receive-footer { @@ -287,7 +395,7 @@ button:disabled { justify-content: space-between; align-items: center; font-size: 12px; - color: #ccc; + color: var(--text-muted); } .footer-left, @@ -308,3 +416,20 @@ button:disabled { font-size: 12px; padding: 0 10px; } + +/* Copy / Save confirmation (non-blocking, disappears by itself) */ +.toast { + position: fixed; + left: 50%; + bottom: 48px; + transform: translateX(-50%); + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: 4px; + background-color: var(--bg-elevated); + color: var(--text); + font-size: 13px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + pointer-events: none; + z-index: 1000; +} diff --git a/src/App.tsx b/src/App.tsx index 2c71298..8b08b94 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,8 +4,12 @@ import { listen } from '@tauri-apps/api/event'; import { save, ask } from '@tauri-apps/plugin-dialog'; import SettingsPanel, { SerialConfig } from './components/SettingsPanel'; import SendPanel from './components/SendPanel'; -import ReceivePanel, { ViewMode } from './components/ReceivePanel'; +import ReceivePanel, { OutputFormat } from './components/ReceivePanel'; import { portPath } from './utils/portUtils'; +import { isSerialConfig } from './utils/serialConfig'; +import { useTheme } from './utils/theme'; +import { useToast } from './utils/useToast'; +import { usePersistentState, loadSetting, saveSetting, isBoolean, isString } from './utils/persist'; import './App.css'; interface DataUpdatePayload { @@ -19,27 +23,62 @@ interface DataUpdatePayload { // while list_ports stays cheap. const PORT_POLL_INTERVAL_MS = 2000; -// Auto reconnect (SYS-F-807). While waiting for a lost device, poll faster than the -// hotplug poll: a board that resets is usually back within a second or two. -const RECONNECT_POLL_MS = 500; -const AUTO_RECONNECT_KEY = 'sme.autoReconnect'; +// Auto Connect (SYS-F-807). While waiting for a device, poll faster than the hotplug +// poll: a board that resets is usually back within a second or two. The C# version +// retried every 200 ms. When the port is listed but cannot be opened (another program +// holds it), back off so that waiting indefinitely stays cheap. +const RECONNECT_POLL_MS = 200; +const RECONNECT_BUSY_POLL_MS = 1000; +const RECONNECT_BUSY_AFTER_FAILURES = 5; + +const DEFAULT_SERIAL_CONFIG: SerialConfig = { + baud_rate: 115200, + data_bits: 8, + flow_control: 'None', + parity: 'None', + stop_bits: 1, + // Both off so that opening the port resets no board: DTR on resets Arduino Uno-type + // boards, RTS on (without DTR) holds ESP32-style boards in reset. Native-USB boards + // may need DTR on to send; the settings panel explains this next to the checkboxes. + dtr: false, + rts: false, +}; + +// The port the user last chose. Saved only on a user choice or Connect, never by the +// automatic fallback, so starting without the device plugged in keeps the preference. +const LAST_PORT_KEY = 'lastPort'; function App() { const [ports, setPorts] = useState([]); - const [selectedPort, setSelectedPort] = useState(''); + // With Auto Connect on, start on the remembered port even if it is absent, so the + // app waits for that device (shown as "not present") instead of another one + const [selectedPort, setSelectedPort] = useState(() => + loadSetting('autoConnect', false, isBoolean) ? loadSetting(LAST_PORT_KEY, '', isString) : '' + ); const [isConnected, setIsConnected] = useState(false); const [totalBytes, setTotalBytes] = useState(0); - const [autoScroll, setAutoScroll] = useState(true); - - const [config, setConfig] = useState({ - baud_rate: 115200, - data_bits: 8, - flow_control: 'None', - parity: 'None', - stop_bits: 1, - dtr: true, - rts: true, - }); + const [theme, setTheme] = useTheme(); + // Confirms Copy / Save, as the C# version's auto-closing message box did + const [toast, showToast] = useToast(); + + // Settings remembered across restarts (issue #7) + const [autoScroll, setAutoScroll] = usePersistentState('receive.autoScroll', true, isBoolean); + const [config, setConfig] = usePersistentState( + 'serialConfig', + DEFAULT_SERIAL_CONFIG, + isSerialConfig + ); + const preferredPortRef = useRef(loadSetting(LAST_PORT_KEY, '', isString)); + // Auto Connect, as in the C# version: while it is on and no port is open, connect to + // the selected port as soon as it can be opened - at startup, when the device is + // plugged in, and after it went away (unplug, board reset). Stored under a new key: + // the old "Auto Reconnect" never opened a port at startup, so it is not carried over. + const [autoConnect, setAutoConnect] = usePersistentState('autoConnect', false, isBoolean); + const rememberPort = useCallback((port: string) => { + if (!port) return; + preferredPortRef.current = port; + saveSetting(LAST_PORT_KEY, port); + }, []); // Read in the poll callback without making it a dependency (the interval // must not be torn down and restarted on every connect/disconnect). @@ -48,26 +87,24 @@ function App() { isConnectedRef.current = isConnected; }, [isConnected]); - // --- Auto reconnect (SYS-F-807) ------------------------------------------- - // When the device we were connected to disappears (unplug, board reset), wait - // for the same device path to come back and reopen it with `reopen_port`, - // which continues the SAME capture (offsets continue, nothing is cleared). - // A user-initiated Disconnect never produces `serial-status connected:false`, - // so every such event is an unexpected disconnect. - const [autoReconnect, setAutoReconnect] = useState( - () => localStorage.getItem(AUTO_RECONNECT_KEY) === 'true' - ); - const autoReconnectRef = useRef(autoReconnect); + // --- Auto Connect (SYS-F-807) --------------------------------------------- + // While waiting, look for the device path and open it with `reopen_port`, which + // continues the SAME capture (offsets continue, nothing is cleared); only the + // Connect button starts a new one. When the connected device disappears, wait for + // the same path again. A user-initiated Disconnect never produces + // `serial-status connected:false`, so every such event is an unexpected disconnect. + const autoConnectRef = useRef(autoConnect); useEffect(() => { - autoReconnectRef.current = autoReconnect; - localStorage.setItem(AUTO_RECONNECT_KEY, String(autoReconnect)); - }, [autoReconnect]); + autoConnectRef.current = autoConnect; + }, [autoConnect]); // Device path we are waiting for (null = not waiting). The ref is updated // synchronously so the port poll and the event listener see it immediately. const [waitingFor, setWaitingFor] = useState(null); const waitingRef = useRef(null); const [reconnectedAt, setReconnectedAt] = useState(null); + // Why the last attempt to open the waited-for port failed (e.g. in use elsewhere) + const [waitError, setWaitError] = useState(null); // The list entry of the current connection (the device to wait for if it goes away) const connectedPortRef = useRef(''); // Reconnect uses the settings as they are when the device comes back @@ -79,6 +116,7 @@ function App() { const beginWaiting = useCallback((path: string) => { waitingRef.current = path; setWaitingFor(path); + setWaitError(null); setReconnectedAt(null); invoke('set_reconnecting', { waiting: true }).catch(console.error); }, []); @@ -87,6 +125,7 @@ function App() { if (waitingRef.current === null) return; waitingRef.current = null; setWaitingFor(null); + setWaitError(null); invoke('set_reconnecting', { waiting: false }).catch(console.error); }, []); @@ -112,11 +151,15 @@ function App() { // device is absent from the list by definition, and moving the selection // would point the wait (and the user) at a different device. if (isConnectedRef.current || waitingRef.current !== null) return current; + // Fallback when nothing valid is selected: the port the user last chose if + // it is present (issue #7), else the first one + const fallback = () => + p.includes(preferredPortRef.current) ? preferredPortRef.current : (p[0] ?? ''); if (current && !p.includes(current)) { - return p.length > 0 ? p[0] : ''; + return fallback(); } if (!current && p.length > 0) { - return p[0]; + return fallback(); } return current; }); @@ -126,6 +169,8 @@ function App() { }, []); useEffect(() => { + // updatePorts is async and sets state only after awaiting list_ports + // eslint-disable-next-line react-hooks/set-state-in-effect updatePorts(); const timer = setInterval(updatePorts, PORT_POLL_INTERVAL_MS); return () => clearInterval(timer); @@ -146,7 +191,7 @@ function App() { // here is safe and puts both sides back in the same state. invoke('close_port').catch(console.error); const lost = connectedPortRef.current; - if (autoReconnectRef.current && lost) { + if (autoConnectRef.current && lost) { // No alert: window.alert blocks the JS thread, so the reconnect would // stall until the user dismissed it. The settings row shows the state. beginWaiting(portPath(lost)); @@ -167,42 +212,63 @@ function App() { }; }, [beginWaiting]); - // While waiting: look for the lost device path and reopen it as soon as it is - // back. A failed open is expected right after re-enumeration (Windows refuses - // the port for a few hundred ms), so failures are retried silently. + // Auto Connect: whenever it is on and nothing is open or awaited, wait for the + // selected port (at startup, after turning it on, after picking another port). + // Started synchronously on purpose: waitingRef must be set before the first port + // poll resolves, or the poll would move the selection off an absent remembered port. + useEffect(() => { + if (autoConnect && !isConnected && waitingFor === null && selectedPort) { + // eslint-disable-next-line react-hooks/set-state-in-effect + beginWaiting(portPath(selectedPort)); + } + }, [autoConnect, isConnected, waitingFor, selectedPort, beginWaiting]); + + // While waiting: look for the device path and open it as soon as it is there. A + // failed open is expected right after re-enumeration (Windows refuses the port for a + // few hundred ms), so failures are retried; after several in a row (the port is in + // use by another program) the retries slow down and the reason is shown. useEffect(() => { if (waitingFor === null) return; let stopped = false; - let busy = false; + let timer: ReturnType | undefined; + let failures = 0; const attempt = async () => { - if (busy || stopped) return; - busy = true; + if (stopped) return; try { const list = await invoke('list_ports'); const entry = list.find((p) => portPath(p) === waitingFor); - if (!entry || stopped) return; - await invoke('reopen_port', { portName: entry, config: configRef.current }); - // Even if the wait was cancelled while this open was in flight, the port is - // now open: show it as connected rather than leave an invisible open port. - connectedPortRef.current = entry; - setPorts(list); - setSelectedPort(entry); - setIsConnected(true); - setReconnectedAt(Date.now()); - endWaiting(); - } catch { - // not ready yet - try again on the next tick - } finally { - busy = false; + if (stopped) return; + if (!entry) { + failures = 0; + setWaitError(null); + } else { + await invoke('reopen_port', { portName: entry, config: configRef.current }); + // Even if the wait was cancelled while this open was in flight, the port is + // now open: show it as connected rather than leave an invisible open port. + connectedPortRef.current = entry; + setPorts(list); + setSelectedPort(entry); + setIsConnected(true); + setReconnectedAt(Date.now()); + endWaiting(); + return; + } + } catch (e) { + failures += 1; + if (failures >= RECONNECT_BUSY_AFTER_FAILURES && !stopped) setWaitError(String(e)); + } + if (!stopped) { + const delay = + failures >= RECONNECT_BUSY_AFTER_FAILURES ? RECONNECT_BUSY_POLL_MS : RECONNECT_POLL_MS; + timer = setTimeout(() => void attempt(), delay); } }; void attempt(); - const timer = setInterval(() => void attempt(), RECONNECT_POLL_MS); return () => { stopped = true; - clearInterval(timer); + clearTimeout(timer); }; }, [waitingFor, endWaiting]); @@ -259,13 +325,17 @@ function App() { }, [config.rts, isConnected]); async function handleToggleConnection() { - // While waiting the button reads "Cancel": stop waiting, keep the capture + // While waiting the button reads "Cancel": stop waiting, keep the capture. Like + // Disconnect it turns Auto Connect off, which would otherwise start waiting again. if (waitingRef.current !== null) { + setAutoConnect(false); endWaiting(); connectedPortRef.current = ''; return; } if (isConnected) { + // C# version: Disconnect turns Auto Connect off, or the port would reopen at once + setAutoConnect(false); try { await invoke('close_port'); connectedPortRef.current = ''; @@ -281,6 +351,7 @@ function App() { // Manual Connect starts a new session (SYS-F-803); only this path resets the count await invoke('open_port', { portName: selectedPort, config: config }); connectedPortRef.current = selectedPort; + rememberPort(selectedPort); setReconnectedAt(null); setIsConnected(true); setTotalBytes(0); @@ -291,24 +362,43 @@ function App() { } } - async function handleExport() { + async function handleExport(format: OutputFormat) { if (totalBytes === 0) { - alert('No data to export'); + showToast('Nothing to save'); return; } + // Timestamp ON (ASCII view): text with a timestamp on every line (issue #4). + // Otherwise, and always in the Hex view, the received bytes exactly as they arrived. + const withTimestamps = format.mode === 'ascii' && format.timestampSeparator !== null; try { - const filePath = await save({ - filters: [ - { name: 'Binary', extensions: ['bin'] }, - { name: 'All Files', extensions: ['*'] }, - ], - defaultPath: 'serial_log.bin', - }); + const filePath = await save( + withTimestamps + ? { + filters: [ + { name: 'Text', extensions: ['txt', 'log'] }, + { name: 'All Files', extensions: ['*'] }, + ], + defaultPath: 'serial_log.txt', + } + : { + filters: [ + { name: 'Binary', extensions: ['bin'] }, + { name: 'All Files', extensions: ['*'] }, + ], + defaultPath: 'serial_log.bin', + } + ); if (filePath) { - const bytesExported = await invoke('export_log', { path: filePath }); + const bytesExported = withTimestamps + ? await invoke('export_text_with_timestamps', { + path: filePath, + separator: format.timestampSeparator, + }) + : await invoke('export_log', { path: filePath }); console.log(`Exported ${bytesExported.toLocaleString()} bytes to ${filePath}`); + showToast('Saved'); } } catch (e) { console.error(e); @@ -326,8 +416,11 @@ function App() { } } - async function handleCopy(mode: ViewMode) { - if (totalBytes === 0) return; + async function handleCopy(format: OutputFormat) { + if (totalBytes === 0) { + showToast('Nothing to copy'); + return; + } if (totalBytes > 10 * 1024 * 1024) { // 10MB @@ -342,9 +435,15 @@ function App() { } try { - const text = await invoke('get_clipboard_text', { mode }); + const text = await invoke('get_clipboard_text', { + mode: format.mode, + timestampSeparator: format.timestampSeparator, + }); if (text) { await navigator.clipboard.writeText(text); + showToast('Copied'); + } else { + showToast('Nothing to copy'); } } catch (e) { console.error(e); @@ -362,6 +461,7 @@ function App() { // Picking another device means the user is no longer waiting for the old one endWaiting(); setSelectedPort(port); + rememberPort(port); }} onRefreshPorts={updatePorts} config={config} @@ -369,13 +469,16 @@ function App() { isConnected={isConnected} onConnect={handleToggleConnection} onDisconnect={handleToggleConnection} - autoReconnect={autoReconnect} - onAutoReconnectChange={(enabled) => { - setAutoReconnect(enabled); + autoConnect={autoConnect} + onAutoConnectChange={(enabled) => { + setAutoConnect(enabled); if (!enabled) endWaiting(); }} reconnectWaitingFor={waitingFor} + reconnectError={waitError} reconnectedAt={reconnectedAt} + theme={theme} + onThemeChange={setTheme} /> @@ -393,6 +496,12 @@ function App() { onAutoScrollChange={setAutoScroll} /> + + {toast !== null && ( +
+ {toast} +
+ )} ); } diff --git a/src/components/ReceivePanel.css b/src/components/ReceivePanel.css index 69196c9..2b61407 100644 --- a/src/components/ReceivePanel.css +++ b/src/components/ReceivePanel.css @@ -15,12 +15,12 @@ .search-input { flex: 1; - border: 1px solid #444; + border: 1px solid var(--border); border-radius: 3px; padding: 4px 8px; font-size: 13px; - background: #2a2a2a; - color: #eee; + background: var(--bg-input); + color: var(--text); height: 28px; box-sizing: border-box; } @@ -31,7 +31,7 @@ } .search-input::placeholder { - color: #888; + color: var(--text-dim); } .filter-toggle { @@ -49,8 +49,8 @@ /* Viewer Wrapper */ .viewer-wrapper { flex: 1; - border: 1px solid #444; - background: #0f0f0f; + border: 1px solid var(--border); + background: var(--bg-sunken); min-height: 0; position: relative; overflow: hidden; @@ -62,7 +62,7 @@ justify-content: space-between; align-items: center; font-size: 12px; - color: #ccc; + color: var(--text-muted); flex-wrap: wrap; gap: 8px; } @@ -112,7 +112,7 @@ .control-separator { width: 1px; height: 16px; - background-color: #555; + background-color: var(--border); margin: 0 4px; } @@ -123,23 +123,23 @@ gap: 6px; padding: 2px 4px; border-radius: 4px; - background: #1a1a1a; + background: var(--bg-panel); } .mode-label { font-size: 11px; font-weight: 500; - color: #666; + color: var(--text-dim); transition: color 0.2s; user-select: none; } .mode-label.active.hex { - color: #ff7043; + color: var(--hex-mode); } .mode-label.active.ascii { - color: #4fc3f7; + color: var(--info); } /* Slide Switch */ @@ -192,3 +192,17 @@ .switch input:focus + .slider { box-shadow: 0 0 3px rgba(79, 195, 247, 0.5); } + +/* Timestamp separator select (was inline-styled) */ +.separator-select { + margin-left: 8px; + background: var(--bg-active); + color: var(--text); + border: 1px solid var(--border-strong); + border-radius: 3px; + padding: 1px 4px; + font-size: 11px; + height: auto; + outline: none; + cursor: pointer; +} diff --git a/src/components/ReceivePanel.tsx b/src/components/ReceivePanel.tsx index 3f60f01..b91c1d1 100644 --- a/src/components/ReceivePanel.tsx +++ b/src/components/ReceivePanel.tsx @@ -1,10 +1,11 @@ import { useState, useCallback } from 'react'; import HexViewer from './viewers/HexViewer'; import AsciiViewer from './viewers/AsciiViewer'; +import { usePersistentState, isBoolean, isOneOf } from '../utils/persist'; import './ReceivePanel.css'; export type ViewMode = 'hex' | 'ascii'; -export type TimestampSeparator = ' ' | ',' | '\t'; +export type TimestampSeparator = ' > ' | ' ' | ',' | '\t'; export interface ReceiveOptions { viewMode: ViewMode; @@ -16,11 +17,23 @@ export interface ReceiveOptions { searchQuery: string; } +/** + * What Save / Copy produce (issue #4). With Timestamp ON, Copy gives every line (Hex: + * every 16-byte row) with its timestamp and the chosen separator, and Save in the + * ASCII view writes the same text. Otherwise the data as before (raw bytes for Save, + * plain or hex text for Copy); Save in the Hex view always writes the raw bytes. + */ +export interface OutputFormat { + mode: ViewMode; + /** Separator after each line's timestamp, or null for no timestamps */ + timestampSeparator: TimestampSeparator | null; +} + interface ReceivePanelProps { totalBytes: number; - onExport: () => void; + onExport: (format: OutputFormat) => void; onClear: () => void; - onCopy: (mode: ViewMode) => void; + onCopy: (format: OutputFormat) => void; autoScroll: boolean; onAutoScrollChange: (enabled: boolean) => void; } @@ -34,12 +47,28 @@ export default function ReceivePanel({ onAutoScrollChange, }: ReceivePanelProps) { // View mode: 'hex' (Binary checked) or 'ascii' (Binary unchecked) - const [viewMode, setViewMode] = useState('ascii'); + // (view options are remembered across restarts - issue #7) + const [viewMode, setViewMode] = usePersistentState( + 'receive.viewMode', + 'ascii', + isOneOf(['ascii', 'hex']) + ); // Display options (ASCII mode only) - const [lineWrap, setLineWrap] = useState(true); - const [showTimestamp, setShowTimestamp] = useState(true); - const [timestampSeparator, setTimestampSeparator] = useState(' '); + const [lineWrap, setLineWrap] = usePersistentState('receive.lineWrap', false, isBoolean); + // C# version: "Show [CRLF]" / "Show [NUL]", both off by default + const [showCrLf, setShowCrLf] = usePersistentState('receive.showCrLf', false, isBoolean); + const [showNul, setShowNul] = usePersistentState('receive.showNul', false, isBoolean); + const [showTimestamp, setShowTimestamp] = usePersistentState( + 'receive.showTimestamp', + true, + isBoolean + ); + const [timestampSeparator, setTimestampSeparator] = usePersistentState( + 'receive.timestampSeparator', + ' > ', + isOneOf([' > ', ' ', ',', '\t']) + ); // Scroll position as byte offset (preserved across mode switches) const [scrollOffset, setScrollOffset] = useState(0); @@ -48,9 +77,13 @@ export default function ReceivePanel({ const [searchQuery, setSearchQuery] = useState(''); const [filterEnabled, setFilterEnabled] = useState(false); - const handleCopy = useCallback(() => { - onCopy(viewMode); - }, [onCopy, viewMode]); + const outputFormat: OutputFormat = { + mode: viewMode, + timestampSeparator: showTimestamp ? timestampSeparator : null, + }; + + const handleCopy = () => onCopy(outputFormat); + const handleExport = () => onExport(outputFormat); const handlePlotter = useCallback(async () => { try { @@ -92,6 +125,7 @@ export default function ReceivePanel({ autoScroll={autoScroll} initialOffset={scrollOffset} onScrollChange={setScrollOffset} + showTimestamp={showTimestamp} /> ) : ( )} @@ -142,34 +178,49 @@ export default function ReceivePanel({ + + + +