From 5bb67d81b23f437743528ef7b8f2f431a33c4176 Mon Sep 17 00:00:00 2001 From: Realer Mason Date: Tue, 8 Sep 2026 23:45:29 +0800 Subject: [PATCH 1/2] feat(serial): support live session configuration updates --- src/design-system/SignalToggle.vue | 2 + .../navigation-status-components.test.ts | 5 +- .../serial-connection-controller.test.ts | 51 +++++ .../__tests__/serial-port-adapter.test.ts | 56 +++++ .../serial-connection-controller.ts | 42 ++++ .../application/serial-connection-types.ts | 2 + .../serial/application/serial-port.ts | 3 + .../infrastructure/tauri-serial-port.ts | 21 ++ .../session-runtime-controller.test.ts | 36 +++ .../session-toolbar-controls.test.ts | 47 ++++ .../sessions/application/serial-bridge.ts | 5 + .../application/session-transceiver.ts | 6 +- src/features/sessions/ports/session-ports.ts | 2 + .../runtime/session-runtime-controller.ts | 75 ++++++- .../settings/session-settings-mutations.ts | 14 +- .../sessions/ui/SessionConnectionControls.vue | 36 ++- .../ui/SessionSerialSettingsDialog.vue | 205 ++++++++++++++++++ src/features/sessions/ui/SessionToolbar.vue | 6 + src/features/sessions/ui/SessionView.vue | 18 ++ src/lib/constants.ts | 30 +++ src/lib/locales/en.ts | 6 + src/lib/locales/zh.ts | 6 + 22 files changed, 663 insertions(+), 11 deletions(-) create mode 100644 src/features/sessions/ui/SessionSerialSettingsDialog.vue diff --git a/src/design-system/SignalToggle.vue b/src/design-system/SignalToggle.vue index 9a35728..178f8c6 100644 --- a/src/design-system/SignalToggle.vue +++ b/src/design-system/SignalToggle.vue @@ -3,6 +3,7 @@ @@ -21,6 +22,7 @@ import { NSwitch } from 'naive-ui'; defineProps<{ label: string; modelValue: boolean; + disabled?: boolean; }>(); const emit = defineEmits<{ diff --git a/src/features/app-shell/__tests__/navigation-status-components.test.ts b/src/features/app-shell/__tests__/navigation-status-components.test.ts index 571e63a..5fd1f58 100644 --- a/src/features/app-shell/__tests__/navigation-status-components.test.ts +++ b/src/features/app-shell/__tests__/navigation-status-components.test.ts @@ -2188,8 +2188,9 @@ test('CreateSessionDialog syncs selected port/config, creates sessions, and save // Re-query before each interaction: under the Teleport stub the modal body // re-mounts per parent render, so element handles captured earlier go // stale. Production teleports patch in place; only the stub remounts. - await wrapper.findAll('select')[2].setValue('3'); - await wrapper.findAll('select')[5].setValue('2'); + wrapper.findAllComponents(AppSelect)[2].vm.$emit('update:value', 57600); + wrapper.findAllComponents(AppSelect)[5].vm.$emit('update:value', 'even'); + await wrapper.vm.$nextTick(); await wrapper.findAll('.modal-positive')[0].trigger('click'); expect(sessionActions.createSession).toHaveBeenCalledWith( 'COM-B', diff --git a/src/features/serial/__tests__/serial-connection-controller.test.ts b/src/features/serial/__tests__/serial-connection-controller.test.ts index e677489..e77e468 100644 --- a/src/features/serial/__tests__/serial-connection-controller.test.ts +++ b/src/features/serial/__tests__/serial-connection-controller.test.ts @@ -44,6 +44,11 @@ class FakePort implements SerialPortAdapter { breakActive = false; readonly controlEvents: string[] = []; readonly clearBufferCalls: string[] = []; + readonly reconfigureCalls: Array<{ + config: PortConfig; + previous?: Readonly; + }> = []; + reconfigureImpl: (config: PortConfig) => Promise = async () => undefined; pendingRxBytes = 7; pendingTxBytes = 3; inputLines = { cts: true, dsr: false, ri: true, cd: false }; @@ -63,6 +68,10 @@ class FakePort implements SerialPortAdapter { this.writes.push(data.slice()); return this.writeImpl(data); } + async reconfigure(config: PortConfig, previous?: Readonly): Promise { + this.reconfigureCalls.push({ config: { ...config }, previous }); + await this.reconfigureImpl(config); + } async writeDataTerminalReady(value: boolean): Promise { await this.dtrImpl(value); this.dtr = value; @@ -152,6 +161,48 @@ function harness( } describe('SerialConnectionController (framework-free)', () => { + test('reconfigures an open port under an exclusive serial transaction', async () => { + const fake = new FakePort(); + const { controller } = harness(fake); + const nextConfig: PortConfig = { + ...config, + baudRate: 921600, + dataBits: 7, + parity: 'even', + rxFrameGapMs: 12, + dtr: true, + }; + + await expect(controller.start()).resolves.toBe(true); + await expect(controller.reconfigure(nextConfig)).resolves.toBeUndefined(); + + expect(fake.reconfigureCalls).toEqual([{ config: nextConfig, previous: config }]); + expect(controller.serialTransactions.snapshot()).toMatchObject({ + phase: 'idle', + manualWriteAllowed: true, + }); + await controller.dispose(); + }); + + test('rolls an open port back when a live reconfiguration fails partway', async () => { + const fake = new FakePort(); + const { controller } = harness(fake); + const nextConfig: PortConfig = { ...config, baudRate: 4000000, parity: 'odd' }; + fake.reconfigureImpl = async (requested) => { + if (requested.baudRate === nextConfig.baudRate) throw new Error('unsupported baud rate'); + }; + + await controller.start(); + await expect(controller.reconfigure(nextConfig)).rejects.toThrow('unsupported baud rate'); + + expect(fake.reconfigureCalls).toEqual([ + { config: nextConfig, previous: config }, + { config, previous: nextConfig }, + ]); + expect(controller.serialTransactions.snapshot().phase).toBe('idle'); + await controller.dispose(); + }); + test('stop during final connection synchronization makes the pending start report cancellation', async () => { const { controller } = harness(new FakePort()); const entered = deferred(); diff --git a/src/features/serial/__tests__/serial-port-adapter.test.ts b/src/features/serial/__tests__/serial-port-adapter.test.ts index 7e59394..10cce26 100644 --- a/src/features/serial/__tests__/serial-port-adapter.test.ts +++ b/src/features/serial/__tests__/serial-port-adapter.test.ts @@ -52,6 +52,26 @@ vi.mock('tauri-plugin-serialplugin-api', () => { return data.length; } + async setBaudRate(value: number): Promise { + mocked.calls.push({ method: 'setBaudRate', args: [value] }); + } + + async setDataBits(value: unknown): Promise { + mocked.calls.push({ method: 'setDataBits', args: [value] }); + } + + async setStopBits(value: unknown): Promise { + mocked.calls.push({ method: 'setStopBits', args: [value] }); + } + + async setParity(value: unknown): Promise { + mocked.calls.push({ method: 'setParity', args: [value] }); + } + + async setFlowControl(value: unknown): Promise { + mocked.calls.push({ method: 'setFlowControl', args: [value] }); + } + async writeDataTerminalReady(value: boolean): Promise { mocked.calls.push({ method: 'dtr', args: [value] }); } @@ -108,6 +128,10 @@ vi.mock('tauri-plugin-serialplugin-api', () => { } return { ClearBuffer: { Input: 'input-native', Output: 'output-native', All: 'all-native' }, + DataBits: { Five: 'five', Six: 'six', Seven: 'seven', Eight: 'eight' }, + StopBits: { One: 'one', Two: 'two' }, + Parity: { None: 'none', Odd: 'odd', Even: 'even' }, + FlowControl: { None: 'none', Software: 'software', Hardware: 'hardware' }, SerialPort, }; }); @@ -133,6 +157,28 @@ test('Tauri serial adapter forwards every v3 operation to the path-scoped plugin await port.open(); await port.watch(handlers, watchOptions); assert.equal(await port.writeBinary(new Uint8Array([1, 2, 3])), 3); + await port.reconfigure?.( + { + baudRate: 230400, + dataBits: 7, + stopBits: 2, + parity: 'even', + flowControl: 'hardware', + rxFrameGapMs: 8, + dtr: true, + rts: false, + }, + { + baudRate: 115200, + dataBits: 8, + stopBits: 1, + parity: 'none', + flowControl: 'none', + rxFrameGapMs: 5, + dtr: false, + rts: false, + }, + ); await port.writeDataTerminalReady(true); await port.writeRequestToSend(false); assert.equal(await port.readClearToSend?.(), true); @@ -163,6 +209,12 @@ test('Tauri serial adapter forwards every v3 operation to the path-scoped plugin 'open', 'watch', 'writeBinary', + 'setBaudRate', + 'setDataBits', + 'setStopBits', + 'setParity', + 'setFlowControl', + 'dtr', 'dtr', 'rts', 'cts', @@ -185,6 +237,10 @@ test('Tauri serial adapter forwards every v3 operation to the path-scoped plugin assert.equal(mocked.calls[0].args[0].path, 'COM9'); assert.equal(mocked.calls[2].args[0], handlers); assert.equal(mocked.calls[2].args[1], watchOptions); + assert.deepEqual( + mocked.calls.slice(4, 10).map((call) => call.args[0]), + [230400, 'seven', 'two', 'even', 'hardware', true], + ); assert.deepEqual( mocked.calls.filter((call) => call.method === 'clearBuffer').map((call) => call.args[0]), ['input-native', 'output-native', 'all-native'], diff --git a/src/features/serial/application/serial-connection-controller.ts b/src/features/serial/application/serial-connection-controller.ts index 616519f..ffdc2e3 100644 --- a/src/features/serial/application/serial-connection-controller.ts +++ b/src/features/serial/application/serial-connection-controller.ts @@ -359,6 +359,47 @@ export function createSerialConnectionController( return () => listeners.delete(listener); } + async function reconfigure(nextConfig: PortConfig): Promise { + const grant = await serialTransactions.acquire(`session-settings:${sessionId}`); + let previousConfig: Readonly | null = null; + let applied = false; + try { + const connection = txPipeline.currentGenerationConnection(grant.generation); + previousConfig = connection.target.config; + if (!connection.port.reconfigure) { + throw new SerialTransactionLeaseError( + 'unavailable', + 'serial reconfiguration is unavailable', + ); + } + try { + await connection.port.reconfigure(nextConfig, previousConfig); + txPipeline.currentGenerationConnection(grant.generation); + } catch (error) { + try { + await connection.port.reconfigure(previousConfig, nextConfig); + } catch (rollbackError) { + logger.warn('serial reconfiguration rollback failed for', sessionId, rollbackError); + } + throw error; + } + connection.target = Object.freeze({ + portName: connection.target.portName, + config: Object.freeze({ ...nextConfig }), + }); + state.trackedOutputLines = Object.freeze({ + ...state.trackedOutputLines, + dtr: nextConfig.dtr, + rts: nextConfig.rts, + }); + rxPipeline.resetRxDrain(nextConfig.rxFrameGapMs); + applied = true; + } finally { + await serialTransactions.release(grant.token); + if (!applied && previousConfig) rxPipeline.resetRxDrain(previousConfig.rxFrameGapMs); + } + } + async function dispose(): Promise { try { return await shutdownEvidence.stop(); @@ -378,6 +419,7 @@ export function createSerialConnectionController( txPipeline.send(data, isHex, writeOptions), sendBytes: (payload: Uint8Array, writeOptions?: SerialWriteOptions) => txPipeline.sendBytes(payload, writeOptions), + reconfigure, sendBreak: (durationMs?: number) => txPipeline.sendBreak(durationMs), rawBytes: (callback: (bytes: Uint8Array) => void) => rxPipeline.rawBytes(callback), serialTransactions, diff --git a/src/features/serial/application/serial-connection-types.ts b/src/features/serial/application/serial-connection-types.ts index e35d924..e35808f 100644 --- a/src/features/serial/application/serial-connection-types.ts +++ b/src/features/serial/application/serial-connection-types.ts @@ -64,6 +64,8 @@ export interface SerialConnectionController { start(): Promise; send(data: string, isHex: boolean, options?: SerialWriteOptions): Promise; sendBytes(payload: Uint8Array, options?: SerialWriteOptions): Promise; + /** Atomically pause session writers and apply settings to the open port. */ + reconfigure(config: PortConfig): Promise; sendBreak(durationMs?: number): Promise; rawBytes(callback: (bytes: Uint8Array) => void): () => void; /** Exclusive protocol transaction boundary for built-in writers. */ diff --git a/src/features/serial/application/serial-port.ts b/src/features/serial/application/serial-port.ts index 5d2e564..ebac0b5 100644 --- a/src/features/serial/application/serial-port.ts +++ b/src/features/serial/application/serial-port.ts @@ -1,5 +1,6 @@ import type { SerialportOptions, WatchHandlers, WatchOptions } from 'tauri-plugin-serialplugin-api'; import type { SerialDrainResponse } from '../../../generated/ipc-contracts'; +import type { PortConfig } from '@/types'; export interface SerialWatchHandleAdapter { unwatch(): Promise; @@ -11,6 +12,8 @@ export interface SerialPortAdapter { open(): Promise; watch(handlers: WatchHandlers, options?: WatchOptions): Promise; writeBinary(data: Uint8Array): Promise; + /** Apply communication parameters without closing the active port. */ + reconfigure?(config: PortConfig, previousConfig?: Readonly): Promise; writeDataTerminalReady(value: boolean): Promise; writeRequestToSend(value: boolean): Promise; readClearToSend?(): Promise; diff --git a/src/features/serial/infrastructure/tauri-serial-port.ts b/src/features/serial/infrastructure/tauri-serial-port.ts index 15d05e5..cd37e91 100644 --- a/src/features/serial/infrastructure/tauri-serial-port.ts +++ b/src/features/serial/infrastructure/tauri-serial-port.ts @@ -2,6 +2,26 @@ import { invoke } from '@tauri-apps/api/core'; import { ClearBuffer, SerialPort } from 'tauri-plugin-serialplugin-api'; import type { SerialDrainRequest, SerialDrainResponse } from '../../../generated/ipc-contracts'; import type { SerialPortFactory } from '../application/serial-port'; +import { mapDataBits, mapFlowControl, mapParity, mapStopBits } from '@/lib/serial-config'; +import type { PortConfig } from '@/types'; + +async function reconfigurePort( + port: SerialPort, + config: PortConfig, + previous?: Readonly, +): Promise { + if (!previous || config.baudRate !== previous.baudRate) await port.setBaudRate(config.baudRate); + if (!previous || config.dataBits !== previous.dataBits) + await port.setDataBits(mapDataBits(config.dataBits)); + if (!previous || config.stopBits !== previous.stopBits) + await port.setStopBits(mapStopBits(config.stopBits)); + if (!previous || config.parity !== previous.parity) + await port.setParity(mapParity(config.parity)); + if (!previous || config.flowControl !== previous.flowControl) + await port.setFlowControl(mapFlowControl(config.flowControl)); + if (!previous || config.dtr !== previous.dtr) await port.writeDataTerminalReady(config.dtr); + if (!previous || config.rts !== previous.rts) await port.writeRequestToSend(config.rts); +} /** Production adapter for tauri-plugin-serialplugin v3. */ export const createTauriSerialPort: SerialPortFactory = (options) => { @@ -10,6 +30,7 @@ export const createTauriSerialPort: SerialPortFactory = (options) => { open: () => port.open(), watch: (handlers, watchOptions) => port.watch(handlers, watchOptions), writeBinary: (data) => port.writeBinary(data), + reconfigure: (config, previous) => reconfigurePort(port, config, previous), writeDataTerminalReady: (value) => port.writeDataTerminalReady(value), writeRequestToSend: (value) => port.writeRequestToSend(value), readClearToSend: () => port.readClearToSend(), diff --git a/src/features/sessions/__tests__/session-runtime-controller.test.ts b/src/features/sessions/__tests__/session-runtime-controller.test.ts index 9ebf93b..1ba9c97 100644 --- a/src/features/sessions/__tests__/session-runtime-controller.test.ts +++ b/src/features/sessions/__tests__/session-runtime-controller.test.ts @@ -176,6 +176,7 @@ interface FakeSerial { stop: ReturnType; send: ReturnType; sendBytes: ReturnType; + reconfigure: ReturnType; sendBreak: ReturnType; rawBytes: ReturnType; dispose: ReturnType; @@ -285,6 +286,7 @@ function makeSerial(): FakeSerial { }), send: vi.fn(async (data: string) => complete(new TextEncoder().encode(data).length)), sendBytes: vi.fn(async (payload: Uint8Array) => complete(payload.length)), + reconfigure: vi.fn(async () => undefined), sendBreak: vi.fn(async () => true), rawBytes: vi.fn((callback: (bytes: Uint8Array) => void) => { observers.add(callback); @@ -592,6 +594,40 @@ test('controller delegates lifecycle commands and releases every resident resour scope.stop(); }); +test('session settings persist offline and reconfigure an established connection in place', async () => { + const { id, runtime, scope, serial, store } = setup(); + const session = sessionById(store.sessions, id); + const offlineConfig: PortConfig = { ...config, baudRate: 230400, rxFrameGapMs: 9 }; + + assert.equal(await runtime.updatePortConfig(offlineConfig), true); + assert.deepEqual(toRaw(session.portConfig), offlineConfig); + assert.equal(serial.reconfigure.mock.calls.length, 0); + + assert.equal(await runtime.connect(), true); + const liveConfig: PortConfig = { + ...offlineConfig, + baudRate: 921600, + dataBits: 7, + parity: 'even', + dtr: true, + }; + assert.equal(await runtime.updatePortConfig(liveConfig), true); + assert.deepEqual(serial.reconfigure.mock.calls[0], [liveConfig]); + assert.deepEqual(toRaw(session.portConfig), liveConfig); + + serial.reconfigure.mockRejectedValueOnce(new Error('unsupported baud rate')); + assert.equal(await runtime.updatePortConfig({ ...liveConfig, baudRate: 4000000 }), false); + assert.deepEqual( + toRaw(session.portConfig), + liveConfig, + 'a failed native update is not persisted', + ); + assert.ok(mocked.message.error.mock.calls.length > 0); + + await runtime.dispose(); + scope.stop(); +}); + test('manual connect joins one pending promise and leaves an already connected port open', async () => { const statuses = new SessionRuntimeStatusRegistry(); const { id, runtime, scope, serial } = setup(statuses); diff --git a/src/features/sessions/__tests__/session-toolbar-controls.test.ts b/src/features/sessions/__tests__/session-toolbar-controls.test.ts index 8a80270..d7ddad4 100644 --- a/src/features/sessions/__tests__/session-toolbar-controls.test.ts +++ b/src/features/sessions/__tests__/session-toolbar-controls.test.ts @@ -5,7 +5,10 @@ import { enableAutoUnmount, mount } from '@vue/test-utils'; import { createPinia } from 'pinia'; import { t } from '@/lib/i18n'; import { createSessionRecord } from '@/lib/session-persistence'; +import { BAUD_RATES } from '@/lib/constants'; +import AppSelect from '@/design-system/AppSelect.vue'; import SessionConnectionControls from '../ui/SessionConnectionControls.vue'; +import SessionSerialSettingsDialog from '../ui/SessionSerialSettingsDialog.vue'; import SessionToolbar from '../ui/SessionToolbar.vue'; enableAutoUnmount(afterEach); @@ -20,6 +23,50 @@ const connectionProps = { sendingBreak: false, }; +test('session toolbar opens serial settings and exposes low-to-high baud rates', async () => { + const wrapper = mount(SessionConnectionControls, { props: connectionProps }); + const settings = wrapper.get(`button[aria-label="${t('serial.settings.open')}"]`); + + await settings.trigger('click'); + expect(wrapper.emitted('settings')).toEqual([[]]); + expect(BAUD_RATES.map((option) => option.value)).toEqual( + expect.arrayContaining([50, 110, 1200, 4800, 115200, 1000000, 4000000]), + ); + + await wrapper.setProps({ reconfiguring: true }); + expect(settings.attributes('disabled')).toBeDefined(); + await settings.trigger('click'); + expect(wrapper.emitted('settings')).toHaveLength(1); +}); + +test('session serial settings dialog emits the edited live configuration', async () => { + const config = createSessionRecord('settings-dialog', 'COM1', { + baudRate: 115200, + dataBits: 8, + stopBits: 1, + parity: 'none', + flowControl: 'none', + rxFrameGapMs: 5, + dtr: false, + rts: false, + }).portConfig; + const wrapper = mount(SessionSerialSettingsDialog, { + props: { show: true, config, connected: true, saving: false }, + global: { stubs: { Teleport: true } }, + }); + + wrapper.findAllComponents(AppSelect)[0].vm.$emit('update:value', 4000000); + wrapper.findAllComponents(AppSelect)[3].vm.$emit('update:value', 'even'); + await wrapper.vm.$nextTick(); + await wrapper.get('.modal-positive').trigger('click'); + + expect(wrapper.emitted('save')?.[0]?.[0]).toMatchObject({ + baudRate: 4000000, + parity: 'even', + }); + expect(wrapper.text()).toContain(t('serial.settings.liveHint')); +}); + test('opening can be cancelled separately and closing cannot issue another open', async () => { const wrapper = mount(SessionConnectionControls, { props: { ...connectionProps, isConnected: false }, diff --git a/src/features/sessions/application/serial-bridge.ts b/src/features/sessions/application/serial-bridge.ts index ed6c845..3e428bb 100644 --- a/src/features/sessions/application/serial-bridge.ts +++ b/src/features/sessions/application/serial-bridge.ts @@ -148,6 +148,10 @@ export class SerialBridge { return this.controller.sendBytes(payload, writeOptions); } + reconfigure(config: PortConfig): Promise { + return this.controller.reconfigure(config); + } + sendBreak(durationMs?: number): Promise { return this.controller.sendBreak(durationMs); } @@ -220,6 +224,7 @@ export function useSerialConnection( bridge.send(data, isHex, writeOptions), sendBytes: (payload: Uint8Array, writeOptions?: SerialWriteOptions) => bridge.sendBytes(payload, writeOptions), + reconfigure: (config: PortConfig) => bridge.reconfigure(config), sendBreak: (durationMs?: number) => bridge.sendBreak(durationMs), rawBytes: (callback: (bytes: Uint8Array) => void) => bridge.rawBytes(callback), serialTransactions: bridge.serialTransactions, diff --git a/src/features/sessions/application/session-transceiver.ts b/src/features/sessions/application/session-transceiver.ts index bad687c..90c092c 100644 --- a/src/features/sessions/application/session-transceiver.ts +++ b/src/features/sessions/application/session-transceiver.ts @@ -2,7 +2,7 @@ import { computed, type ComputedRef, type Ref } from 'vue'; import type { McumgrTraceFrame } from '@/generated/ipc-contracts'; import { mcumgrTraceFramesToDataFrames } from '@/lib/mcumgr-trace'; import { sessionCaptureTimeline, type SessionCaptureTimeline } from '@/lib/capture-stream'; -import type { DataFrame, SerialSendResult, SerialWriteOptions } from '@/types'; +import type { DataFrame, PortConfig, SerialSendResult, SerialWriteOptions } from '@/types'; import type { SessionCapturePort } from '@/features/sessions/ports/session-ports'; import type { SerialAutomationPausePort } from '@/features/serial'; import { @@ -150,6 +150,10 @@ export class SessionTransceiver implements SessionFeatureTransport { return this.serial.sendBytes(payload, options); } + reconfigure(config: PortConfig): Promise { + return this.serial.reconfigure(config); + } + sendBreak(durationMs?: number): Promise { return this.serial.sendBreak(durationMs); } diff --git a/src/features/sessions/ports/session-ports.ts b/src/features/sessions/ports/session-ports.ts index a378d6b..b04d3ad 100644 --- a/src/features/sessions/ports/session-ports.ts +++ b/src/features/sessions/ports/session-ports.ts @@ -172,6 +172,7 @@ export type SessionDocumentPort = Readonly<{ | 'setModbusConfig' | 'setShellConfig' | 'setMcumgrConfig' + | 'setPortConfig' | 'setWaveformSourceMode' | 'setAutoLogTarget' | 'setTerminalAiModel' @@ -276,6 +277,7 @@ export function useSessionDocument(sessionId: string): SessionDocumentPort { setModbusConfig: facade.setModbusConfig, setShellConfig: facade.setShellConfig, setMcumgrConfig: facade.setMcumgrConfig, + setPortConfig: facade.setPortConfig, setWaveformSourceMode: facade.setWaveformSourceMode, setAutoLogTarget: facade.setAutoLogTarget, setTerminalAiModel: facade.setTerminalAiModel, diff --git a/src/features/sessions/runtime/session-runtime-controller.ts b/src/features/sessions/runtime/session-runtime-controller.ts index 8d16d0a..9e9f7f6 100644 --- a/src/features/sessions/runtime/session-runtime-controller.ts +++ b/src/features/sessions/runtime/session-runtime-controller.ts @@ -9,7 +9,11 @@ import { type Ref, } from 'vue'; import { useAppStore } from '@/features/settings/store/app-store'; -import { useSessionCapture, useSessionDocument } from '@/features/sessions/ports/session-ports'; +import { + useSessionCapture, + useSessionDocument, + useSessionMutationPolicy, +} from '@/features/sessions/ports/session-ports'; import { createSerialBridge, serialConnectionFailureMessage, @@ -35,7 +39,13 @@ import { t } from '@/lib/i18n'; import { logger } from '@/lib/logger'; import { SerialUiPublishScheduler } from '@/lib/serial-rx-scheduler'; import type { DisplayProtocolRecord } from '@/lib/protocol-record'; -import type { DataFrame, SerialSendResult, SerialSession, SerialWriteOptions } from '@/types'; +import type { + DataFrame, + PortConfig, + SerialSendResult, + SerialSession, + SerialWriteOptions, +} from '@/types'; import type { SerialConnectionFailure } from '@/features/sessions/application/serial-bridge'; import { SessionProtocolRuntime } from './session-protocol-runtime'; import { SessionRuntimeStatusRegistry } from './session-runtime-status'; @@ -73,6 +83,19 @@ function assertSerialStopEvidence(result: SerialStopResult): void { } } +function portConfigsEqual(left: Readonly, right: Readonly): boolean { + return ( + left.baudRate === right.baudRate && + left.dataBits === right.dataBits && + left.stopBits === right.stopBits && + left.parity === right.parity && + left.flowControl === right.flowControl && + left.rxFrameGapMs === right.rxFrameGapMs && + left.dtr === right.dtr && + left.rts === right.rts + ); +} + export interface SessionRuntimeWaveformSink { pushRegisterSample: (channel: number, value: number, timestamp?: number) => void; pushRegisterSamples: ( @@ -158,6 +181,7 @@ export interface SessionRuntimeController { readonly connectionFailure: Readonly>; readonly totalDroppedBytes: Readonly>; readonly sendingBreak: Readonly>; + readonly reconfiguring: Readonly>; readonly looping: Readonly>; readonly viewMode: Ref; /** View-local UI state retained across SessionView remounts. */ @@ -177,6 +201,7 @@ export interface SessionRuntimeController { clearRawData: () => void; setCapturePaused: (paused: boolean) => void; sendBreak: () => Promise; + updatePortConfig: (config: PortConfig) => Promise; startSendLoop: (data: string, isHex: boolean) => boolean; stopSendLoop: () => void; toggleAutoLog: () => Promise; @@ -193,6 +218,7 @@ export function useSessionRuntimeController( const instanceId = `${session.value.id}:${++nextRuntimeInstanceId}`; const capture = useSessionCapture(session.value.id); const sessionDocument = useSessionDocument(session.value.id); + const mutationPolicy = useSessionMutationPolicy(); const appStore = useAppStore(); const notifications = dependencies.notifications; const bridgeFactory = dependencies.bridgeFactory ?? defaultBridgeFactory; @@ -465,6 +491,7 @@ export function useSessionRuntimeController( }; const sendingBreak = ref(false); + const reconfiguring = ref(false); const looping = ref(false); let loopPayload: { data: string; isHex: boolean } | null = null; const sendLoop = new AsyncSendLoop( @@ -633,6 +660,48 @@ export function useSessionRuntimeController( } } + async function updatePortConfig(config: PortConfig): Promise { + if ( + disposed || + preparePromise || + reconfiguring.value || + isConnecting.value || + isClosing.value || + mcumgr.busy.value || + !mutationPolicy.userMutationsAllowed.value + ) { + return false; + } + + const previous = { ...session.value.portConfig }; + if (portConfigsEqual(previous, config)) return true; + + reconfiguring.value = true; + let nativeApplied = false; + try { + if (transceiver.isConnected.value) { + await transceiver.reconfigure(config); + nativeApplied = true; + } + if (!sessionDocument.setPortConfig(session.value.id, config)) { + if (nativeApplied) await transceiver.reconfigure(previous); + return false; + } + notifications.success(t('serial.settings.updated')); + return true; + } catch (error) { + logger.warn('serial reconfiguration failed for', session.value.id, error); + notifications.error( + t('serial.settings.updateFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + return false; + } finally { + reconfiguring.value = false; + } + } + function startSendLoop(data: string, isHex: boolean): boolean { if ( disposed || @@ -781,6 +850,7 @@ export function useSessionRuntimeController( connectionFailure: readonly(transceiver.connectionFailure), totalDroppedBytes: readonly(transceiver.totalDroppedBytes), sendingBreak: readonly(sendingBreak), + reconfiguring: readonly(reconfiguring), looping: readonly(looping), viewMode, uiState, @@ -798,6 +868,7 @@ export function useSessionRuntimeController( clearRawData: () => transceiver.clearRawData(), setCapturePaused: (paused) => transceiver.setCapturePaused(paused), sendBreak, + updatePortConfig, startSendLoop, stopSendLoop, toggleAutoLog, diff --git a/src/features/sessions/settings/session-settings-mutations.ts b/src/features/sessions/settings/session-settings-mutations.ts index e7e8b90..2d983d6 100644 --- a/src/features/sessions/settings/session-settings-mutations.ts +++ b/src/features/sessions/settings/session-settings-mutations.ts @@ -1,5 +1,5 @@ import type { ParserConfig } from '@/lib/protocol-parser'; -import { cloneParserConfig } from '@/lib/session-persistence'; +import { cloneParserConfig, normalizePortConfig } from '@/lib/session-persistence'; import { normalizeLogAiFrameLimit } from '@/lib/session-store-helpers'; import { cloneModbusConfig } from '@/lib/modbus'; import { cloneSerialShellConfig } from '@/lib/serial-shell'; @@ -8,6 +8,7 @@ import type { AiModel, LogAiContextMode } from '@/types/ai'; import type { ModbusMasterConfig } from '@/types/modbus'; import type { McumgrClientConfig } from '@/types/mcumgr'; import type { SerialSession } from '@/types/session'; +import type { PortConfig } from '@/types/serial'; import type { SerialShellConfig } from '@/types/serial-shell'; import type { WaveformSourceMode } from '@/types/waveform'; @@ -68,6 +69,16 @@ export function createSessionSettingsMutations({ onSessionChanged(sessionId); } + function setPortConfig(sessionId: string, config: PortConfig): boolean { + if (!canMutateUserState()) return false; + const session = findSession(sessionId); + if (!session) return false; + session.portConfig = normalizePortConfig(config); + schedulePersist(sessionId); + onSessionChanged(sessionId); + return true; + } + function setWaveformSourceMode(sessionId: string, mode: WaveformSourceMode) { if (!canMutateUserState()) return; const session = findSession(sessionId); @@ -130,6 +141,7 @@ export function createSessionSettingsMutations({ setModbusConfig, setShellConfig, setMcumgrConfig, + setPortConfig, setWaveformSourceMode, setAutoLogTarget, setTerminalAiModel, diff --git a/src/features/sessions/ui/SessionConnectionControls.vue b/src/features/sessions/ui/SessionConnectionControls.vue index aeec6a5..fc0e680 100644 --- a/src/features/sessions/ui/SessionConnectionControls.vue +++ b/src/features/sessions/ui/SessionConnectionControls.vue @@ -5,7 +5,7 @@ size="small" :type="buttonType" :loading="transitioning" - :disabled="connectionLocked || transitioning" + :disabled="settingsDisabled || connectionLocked || transitioning || reconfiguring" :aria-label="buttonLabel" :aria-busy="transitioning" @click="onPrimaryAction" @@ -40,7 +40,19 @@ {{ statusLabel }} - {{ baudRate }} bps + + + {{ baudRate }} bps + @@ -86,7 +98,17 @@ + + diff --git a/src/features/sessions/ui/SessionToolbar.vue b/src/features/sessions/ui/SessionToolbar.vue index 0876fae..04e9f4c 100644 --- a/src/features/sessions/ui/SessionToolbar.vue +++ b/src/features/sessions/ui/SessionToolbar.vue @@ -13,12 +13,15 @@ :capture-paused="captureIsPaused" :can-clear="captureCanClear" :sending-break="sendingBreak" + :reconfiguring="reconfiguring" + :settings-disabled="settingsDisabled" @connect="$emit('connect')" @disconnect="$emit('disconnect')" @rebind="$emit('rebind')" @clear="$emit('clear')" @toggle-pause="$emit('toggle-pause')" @send-break="$emit('send-break')" + @settings="$emit('settings')" />
@@ -119,6 +122,8 @@ const props = withDefaults( viewMode: SessionViewMode; /** True while MCUmgr owns the port; connection controls must stay locked. */ connectionLocked?: boolean; + reconfiguring?: boolean; + settingsDisabled?: boolean; }>(), { captureHasData: undefined, capturePaused: undefined }, ); @@ -136,6 +141,7 @@ const emit = defineEmits<{ export: []; 'show-conflicting-session': [sessionId: string]; rebind: []; + settings: []; }>(); const appStore = useAppStore(); diff --git a/src/features/sessions/ui/SessionView.vue b/src/features/sessions/ui/SessionView.vue index 80dd4f4..1c97288 100644 --- a/src/features/sessions/ui/SessionView.vue +++ b/src/features/sessions/ui/SessionView.vue @@ -25,6 +25,8 @@ :connection-conflict="runtime.connectionFailure.value?.conflict" :needs-rebind="Boolean(rebindMetadata)" :sending-break="runtime.sendingBreak.value" + :reconfiguring="runtime.reconfiguring.value" + :settings-disabled="!mutationPolicy.userMutationsAllowed.value" :is-exporting="isExporting" :view-mode="viewMode" :connection-locked="runtime.mcumgr.busy.value" @@ -40,6 +42,15 @@ @export="openExportDialog" @show-conflicting-session="showConflictingSession" @rebind="rebindDialogVisible = true" + @settings="serialSettingsVisible = true" + /> + catalog.workspaceRebindBySessionId.value[props.session.id] ?? null, ); +async function saveSerialSettings(config: PortConfig): Promise { + if (await runtime.updatePortConfig(config)) serialSettingsVisible.value = false; +} + async function connect() { await runtime.connect(); } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4a6df34..faf054d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -2,14 +2,44 @@ import type { DisplayMode } from '@/types'; import type { ExportFormat as ContractExportFormat } from '@/generated/ipc-contracts'; export const BAUD_RATES = [ + { label: '50', value: 50 }, + { label: '75', value: 75 }, + { label: '110', value: 110 }, + { label: '134', value: 134 }, + { label: '150', value: 150 }, + { label: '200', value: 200 }, + { label: '300', value: 300 }, + { label: '600', value: 600 }, + { label: '1200', value: 1200 }, + { label: '1800', value: 1800 }, + { label: '2400', value: 2400 }, + { label: '4800', value: 4800 }, + { label: '7200', value: 7200 }, { label: '9600', value: 9600 }, + { label: '14400', value: 14400 }, { label: '19200', value: 19200 }, + { label: '28800', value: 28800 }, { label: '38400', value: 38400 }, + { label: '56000', value: 56000 }, { label: '57600', value: 57600 }, + { label: '76800', value: 76800 }, { label: '115200', value: 115200 }, + { label: '128000', value: 128000 }, + { label: '153600', value: 153600 }, { label: '230400', value: 230400 }, + { label: '256000', value: 256000 }, { label: '460800', value: 460800 }, + { label: '500000', value: 500000 }, + { label: '576000', value: 576000 }, { label: '921600', value: 921600 }, + { label: '1000000', value: 1000000 }, + { label: '1152000', value: 1152000 }, + { label: '1500000', value: 1500000 }, + { label: '2000000', value: 2000000 }, + { label: '2500000', value: 2500000 }, + { label: '3000000', value: 3000000 }, + { label: '3500000', value: 3500000 }, + { label: '4000000', value: 4000000 }, ]; export const DATA_BITS_OPTIONS = [ diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index 3dbb6e1..8133510 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -239,6 +239,12 @@ const en: Catalog = { 'serial.signals': 'Signals', 'serial.signalControl': 'Signal control', 'serial.signalHint': 'DTR/RTS are used for Arduino reset, ESP32 boot modes, and similar flows', + 'serial.settings.title': 'Serial settings', + 'serial.settings.open': 'Edit serial settings', + 'serial.settings.liveHint': 'Changes are applied immediately without closing the session.', + 'serial.settings.nextOpenHint': 'Changes will be used the next time this session connects.', + 'serial.settings.updated': 'Serial settings updated', + 'serial.settings.updateFailed': 'Could not update serial settings: {error}', 'serial.summary': 'Current connection summary', 'serial.rate': 'Rate', 'serial.format': 'Format', diff --git a/src/lib/locales/zh.ts b/src/lib/locales/zh.ts index b53c041..affed5a 100644 --- a/src/lib/locales/zh.ts +++ b/src/lib/locales/zh.ts @@ -222,6 +222,12 @@ const zh: Catalog = { 'serial.signals': '信号', 'serial.signalControl': '信号控制', 'serial.signalHint': 'DTR/RTS 用于 Arduino 复位、ESP32 启动模式等', + 'serial.settings.title': '串口设置', + 'serial.settings.open': '修改串口设置', + 'serial.settings.liveHint': '保存后立即生效,无需断开当前会话。', + 'serial.settings.nextOpenHint': '设置将在此会话下次连接时生效。', + 'serial.settings.updated': '串口设置已更新', + 'serial.settings.updateFailed': '串口设置更新失败:{error}', 'serial.summary': '当前连接摘要', 'serial.rate': '速率', 'serial.format': '格式', From 107b5cc4f61c95b5147fef5b90ae1f623de1823b Mon Sep 17 00:00:00 2001 From: Realer Mason Date: Sat, 12 Sep 2026 14:13:13 +0800 Subject: [PATCH 2/2] fix(terminal): bundle monospace fonts and prepare v1.2.1 --- CHANGELOG.md | 14 +++ Cargo.lock | 2 +- index.html | 8 +- package.json | 2 +- .../jetbrains-mono/JetBrainsMono-Bold.woff2 | Bin 0 -> 94588 bytes .../JetBrainsMono-Regular.woff2 | Bin 0 -> 92164 bytes public/fonts/jetbrains-mono/OFL.txt | 93 ++++++++++++++++ public/fonts/jetbrains-mono/README.md | 11 ++ src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/bootstrap/main.ts | 1 + src/design-system/fonts.css | 16 +++ src/design-system/tokens/typography.css | 1 + .../__tests__/serial-shell-panel.test.ts | 100 +++++++++++++++--- src/features/terminal/ui/SerialShellPanel.vue | 31 +++++- 15 files changed, 257 insertions(+), 26 deletions(-) create mode 100644 public/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 create mode 100644 public/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 create mode 100644 public/fonts/jetbrains-mono/OFL.txt create mode 100644 public/fonts/jetbrains-mono/README.md create mode 100644 src/design-system/fonts.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cfaf11..96be6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to bbcom are documented here. The format is based on ## [Unreleased] +## [1.2.1] - 2026-09-12 + +### Added + +- Serial sessions support updating connection settings while connected. + +### Fixed + +- Bundle JetBrains Mono Regular and Bold for consistent offline terminal fonts. +- Wait for terminal fonts before measuring character cells and replaying output; + use system monospace on font load failure and skip setup after unmounting. +- Adjust Shell font sizing and line spacing and disable ligatures and kerning + to keep serial output aligned. + ## [1.2.0] - 2026-09-08 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 0a28013..562baa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,7 +280,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bbcom" -version = "1.2.0" +version = "1.2.1" dependencies = [ "base64 0.22.1", "bbcom-contracts", diff --git a/index.html b/index.html index f818df5..423c563 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,13 @@ document, fall back to the read-only v1 app-settings blob. main.ts mounts into #app and replaces the skeleton, so there is no layout shift. --> - +