diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fc9fc0..cc67b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ History before 0.1.0 is not reconstructed here; see the change log in ## [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. +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 @@ -25,6 +26,12 @@ Windows build: NSIS installer only, as for beta.1. 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 @@ -34,9 +41,23 @@ Windows build: NSIS installer only, as for beta.1. - 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 Reconnect status moved to the MCP Server + 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 @@ -47,6 +68,12 @@ Windows build: NSIS installer only, as for beta.1. - 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 diff --git a/README.md b/README.md index 3775422..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 diff --git a/docs/21_system_requirements.md b/docs/21_system_requirements.md index 6b64a70..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) | @@ -184,7 +184,7 @@ 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-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 486aff9..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` / `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 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) | @@ -112,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/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/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/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/serial/mod.rs b/src-tauri/src/serial/mod.rs index a3bab63..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)) + }), }); } } @@ -533,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 と同じ一覧を返せるよう、コマンド属性の @@ -545,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) @@ -603,7 +638,7 @@ pub fn export_log(state: State<'_, SerialState>, path: String) -> Result Result<(), String> { match separator { - " " | "," | "\t" => Ok(()), + " > " | " " | "," | "\t" => Ok(()), other => Err(format!("Unsupported timestamp separator: {:?}", other)), } } @@ -695,8 +730,16 @@ pub fn get_clipboard_text( 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), false) = (timestamp_separator.as_deref(), mode == "hex") { + 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)?; @@ -761,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/App.css b/src/App.css index 8082825..20522e8 100644 --- a/src/App.css +++ b/src/App.css @@ -278,8 +278,20 @@ 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 var(--border); @@ -287,7 +299,9 @@ button:disabled { 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; } @@ -402,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 a4bf27a..8b08b94 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ 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'; @@ -22,9 +23,13 @@ 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; +// 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, @@ -32,8 +37,11 @@ const DEFAULT_SERIAL_CONFIG: SerialConfig = { flow_control: 'None', parity: 'None', stop_bits: 1, - dtr: true, - rts: true, + // 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 @@ -42,10 +50,16 @@ 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 [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); @@ -55,6 +69,11 @@ function App() { 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; @@ -68,23 +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] = usePersistentState('autoReconnect', false, isBoolean); - 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; - }, [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 @@ -96,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); }, []); @@ -104,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); }, []); @@ -147,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); @@ -167,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)); @@ -188,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]); @@ -280,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 = ''; @@ -315,13 +364,13 @@ function App() { 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 the received bytes exactly as they arrived. - const withTimestamps = format.timestampSeparator !== null; + // 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( withTimestamps @@ -349,6 +398,7 @@ function App() { }) : await invoke('export_log', { path: filePath }); console.log(`Exported ${bytesExported.toLocaleString()} bytes to ${filePath}`); + showToast('Saved'); } } catch (e) { console.error(e); @@ -367,7 +417,10 @@ function App() { } async function handleCopy(format: OutputFormat) { - if (totalBytes === 0) return; + if (totalBytes === 0) { + showToast('Nothing to copy'); + return; + } if (totalBytes > 10 * 1024 * 1024) { // 10MB @@ -388,6 +441,9 @@ function App() { }); if (text) { await navigator.clipboard.writeText(text); + showToast('Copied'); + } else { + showToast('Nothing to copy'); } } catch (e) { console.error(e); @@ -413,12 +469,13 @@ 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} @@ -439,6 +496,12 @@ function App() { onAutoScrollChange={setAutoScroll} /> + + {toast !== null && ( +
+ {toast} +
+ )} ); } diff --git a/src/components/ReceivePanel.tsx b/src/components/ReceivePanel.tsx index aaa9d33..b91c1d1 100644 --- a/src/components/ReceivePanel.tsx +++ b/src/components/ReceivePanel.tsx @@ -5,7 +5,7 @@ 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; @@ -18,9 +18,10 @@ export interface ReceiveOptions { } /** - * What Save / Copy produce (issue #4): with Timestamp ON in the ASCII view, the text - * with a timestamp and the chosen separator at the start of every line; otherwise - * the data as before (raw bytes for Save, plain or hex text for Copy). + * 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; @@ -54,7 +55,10 @@ export default function ReceivePanel({ ); // Display options (ASCII mode only) - const [lineWrap, setLineWrap] = usePersistentState('receive.lineWrap', true, isBoolean); + 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, @@ -62,8 +66,8 @@ export default function ReceivePanel({ ); const [timestampSeparator, setTimestampSeparator] = usePersistentState( 'receive.timestampSeparator', - ' ', - isOneOf([' ', ',', '\t']) + ' > ', + isOneOf([' > ', ' ', ',', '\t']) ); // Scroll position as byte offset (preserved across mode switches) @@ -75,7 +79,7 @@ export default function ReceivePanel({ const outputFormat: OutputFormat = { mode: viewMode, - timestampSeparator: viewMode === 'ascii' && showTimestamp ? timestampSeparator : null, + timestampSeparator: showTimestamp ? timestampSeparator : null, }; const handleCopy = () => onCopy(outputFormat); @@ -121,6 +125,7 @@ export default function ReceivePanel({ autoScroll={autoScroll} initialOffset={scrollOffset} onScrollChange={setScrollOffset} + showTimestamp={showTimestamp} /> ) : ( )} @@ -171,23 +178,49 @@ export default function ReceivePanel({ + + + +