diff --git a/packages/uhk-agent/src/services/device.service.ts b/packages/uhk-agent/src/services/device.service.ts index b2e69809e57..174011cada7 100644 --- a/packages/uhk-agent/src/services/device.service.ts +++ b/packages/uhk-agent/src/services/device.service.ts @@ -2,7 +2,6 @@ import { ipcMain } from 'electron'; import { cloneDeep, isEqual } from 'lodash'; import { rm } from 'node:fs/promises'; import os from 'node:os'; -import { UhkDeviceProduct } from 'uhk-common'; import { ALL_UHK_DEVICES, AreBleAddressesPairedIpcResponse, @@ -14,6 +13,7 @@ import { convertBleStringToNumberArray, CurrentlyUpdatingModuleInfo, DeviceConnectionState, + escapeZephyrControlChars, findUhkModuleById, FIRMWARE_UPGRADE_METHODS, FirmwareUpgradeIpcResponse, @@ -47,6 +47,7 @@ import { UHK_DONGLE, UHK_MODULE_IDS, UHK_MODULES, + UhkDeviceProduct, UpdateFirmwareData, UploadFileData, VERSIONS, @@ -125,6 +126,7 @@ export class DeviceService { currentDeviceFn: getCurrentUhkDongleHID, logService: this.logService, ipcEvents: { + execShellCommand: IpcEvents.device.execShellCommandOnDongle, isZephyrLoggingEnabled: IpcEvents.device.isDongleZephyrLoggingEnabled, isZephyrLoggingEnabledReply: IpcEvents.device.isDongleZephyrLoggingEnabledReply, toggleZephyrLogging: IpcEvents.device.toggleDongleZephyrLogging, @@ -138,6 +140,7 @@ export class DeviceService { currentDeviceFn: getCurrenUhk80LeftHID, logService: this.logService, ipcEvents: { + execShellCommand: IpcEvents.device.execShellCommandOnLeftHalf, isZephyrLoggingEnabled: IpcEvents.device.isLeftHalfZephyrLoggingEnabled, isZephyrLoggingEnabledReply: IpcEvents.device.isLeftHalfZephyrLoggingEnabledReply, toggleZephyrLogging: IpcEvents.device.toggleLeftHalfZephyrLogging, @@ -195,6 +198,15 @@ export class DeviceService { }); }); + ipcMain.on(IpcEvents.device.execShellCommandOnRightHalf, (...args) => { + this.queueManager.add({ + method: this.execShellCommand, + bind: this, + params: args, + asynchronous: true + }); + }); + ipcMain.on(IpcEvents.device.toggleI2cDebugging, this.toggleI2cDebugging.bind(this)); ipcMain.on(IpcEvents.device.isRightHalfZephyrLoggingEnabled, (...args) => { @@ -973,6 +985,25 @@ export class DeviceService { event.sender.send(IpcEvents.device.eraseBleSettingsReply, response); } + public async execShellCommand(_: Electron.IpcMainEvent, [command]): Promise { + this.logService.misc(`[DeviceService] execute shell command: ${command}`); + + try { + await this.stopPollUhkDevice(); + await this.operations.execShellCommand(command); + this.logService.misc('[DeviceService] execute shell command success'); + // give some time for the command to complete + await snooze(5); + await this.readZephyrLog(); + } + catch(error) { + this.logService.error('[DeviceService] execute shell command failed', error); + } + finally { + this.startPollUhkDevice(); + } + } + public async startDonglePairing(event: Electron.IpcMainEvent): Promise { this.logService.misc('[DeviceService] start Dongle pairing'); try { @@ -1422,7 +1453,7 @@ export class DeviceService { try { const uhkDeviceProduct = await getCurrentUhkDeviceProduct(this.options); const log = await this.operations.getVariable(UsbVariables.ShellBuffer) - this.logService.misc(`[DeviceService] Right half zephyr log: ${log}`); + this.logService.misc(`[DeviceService] Right half zephyr log (escaped): ⟦${escapeZephyrControlChars(log as string)}⟧`); const logEntry: ZephyrLogEntry = { log: log as string, level: 'info', diff --git a/packages/uhk-agent/src/services/zephyr-log.service.ts b/packages/uhk-agent/src/services/zephyr-log.service.ts index 1172f5ac0d1..08411720693 100644 --- a/packages/uhk-agent/src/services/zephyr-log.service.ts +++ b/packages/uhk-agent/src/services/zephyr-log.service.ts @@ -1,6 +1,13 @@ import { ipcMain } from 'electron'; import pLimit from 'p-limit'; -import { CommandLineArgs, IpcEvents, LogService, UhkDeviceProduct, ZephyrLogEntry } from 'uhk-common' +import { + CommandLineArgs, + escapeZephyrControlChars, + IpcEvents, + LogService, + UhkDeviceProduct, + ZephyrLogEntry, +} from 'uhk-common' import { UsbVariables } from 'uhk-usb'; import { getCurrentUhkDongleHID, getCurrenUhk80LeftHID, snooze, UhkHidDevice, UhkOperations, } from 'uhk-usb' @@ -11,6 +18,7 @@ export interface ZephyrLogServiceOptions { currentDeviceFn: typeof getCurrenUhk80LeftHID | typeof getCurrentUhkDongleHID; logService: LogService; ipcEvents: { + execShellCommand: string; isZephyrLoggingEnabled: string; isZephyrLoggingEnabledReply: string; toggleZephyrLogging: string; @@ -31,6 +39,15 @@ export class ZephyrLogService { private operationLimiter = pLimit(1); constructor(private options: ZephyrLogServiceOptions) { + ipcMain.on(options.ipcEvents.execShellCommand, (...args) => { + this.queueManager.add({ + method: this.execShellCommand, + bind: this, + params: args, + asynchronous: true + }); + }); + ipcMain.on(options.ipcEvents.isZephyrLoggingEnabled, (...args) => { this.queueManager.add({ method: this.isZephyrLoggingEnabled, @@ -81,6 +98,31 @@ export class ZephyrLogService { this.options.logService.misc(`[ZephyrLogService | ${this.options.uhkDeviceProduct.logName}] Disabled`); } + private async execShellCommand(_: Electron.IpcMainEvent, [command]): Promise { + try { + await this.pauseLogging(); + + const operations = await this.getOperations(); + if (!operations) { + const logEntry: ZephyrLogEntry = { + log: "Device is not connected. Can't execute shell command", + level: 'error', + device: this.options.uhkDeviceProduct.logName, + } + this.options.win.webContents.send(IpcEvents.device.zephyrLog, logEntry) + return; + } + await operations.execShellCommand(command); + this.options.logService.misc(`[ZephyrLogService | ${this.options.uhkDeviceProduct.logName}] execute shell command success`); + // give some time for the command to complete + await snooze(5); + await this.readZephyrLog(operations); + } + finally { + await this.resumeLogging(); + } + } + private async getOperations(logEarlierInited = true): Promise { if (logEarlierInited) { this.options.logService.misc(`[ZephyrLogService | ${this.options.uhkDeviceProduct.logName}] getOperations`); @@ -161,6 +203,28 @@ export class ZephyrLogService { this.options.logService.misc(`[ZephyrLogService | ${this.options.uhkDeviceProduct.logName}] paused logging`); } + private async readZephyrLog(operations: UhkOperations): Promise { + try { + const log = (await operations.getVariable(UsbVariables.ShellBuffer)) as string; + this.options.logService.misc(`[ZephyrLogService | ${this.options.uhkDeviceProduct.logName}] Zephyr log (escaped): ${escapeZephyrControlChars(log)}`); + const logEntry: ZephyrLogEntry = { + log: log as string, + level: 'info', + device: this.options.uhkDeviceProduct.logName, + } + this.options.win.webContents.send(IpcEvents.device.zephyrLog, logEntry) + } + catch (error) { + this.options.logService.error(`[ZephyrLogService | ${this.options.uhkDeviceProduct.logName}] can't read zephyr log`, error); + const logEntry: ZephyrLogEntry = { + log: error.message as string, + level: 'error', + device: this.options.uhkDeviceProduct.logName, + } + this.options.win.webContents.send(IpcEvents.device.zephyrLog, logEntry) + } + } + private async resumeLogging(): Promise { if (!this.isPaused) { return; @@ -188,18 +252,11 @@ export class ZephyrLogService { const deviceState = await this.uhkHidDevice.getDeviceState(); if (deviceState.isZephyrLogAvailable) { - const log = await operations.getVariable(UsbVariables.ShellBuffer) - this.options.logService.misc(`[ZephyrLogService | ${this.options.uhkDeviceProduct.logName}] Zephyr log: ${log}`); - const logEntry: ZephyrLogEntry = { - log: log as string, - level: 'info', - device: this.options.uhkDeviceProduct.logName, - } - this.options.win.webContents.send(IpcEvents.device.zephyrLog, logEntry) + await this.readZephyrLog(operations); } } catch (error) { - this.options.logService.error(`[ZephyrLogService | ${this.options.uhkDeviceProduct.logName}] Can't read log`, error); + this.options.logService.error(`[ZephyrLogService | ${this.options.uhkDeviceProduct.logName}] Can't poll log`, error); const logEntry: ZephyrLogEntry = { log: error.message, level: 'error', diff --git a/packages/uhk-common/src/log/escape-zephyr-control-chars.ts b/packages/uhk-common/src/log/escape-zephyr-control-chars.ts new file mode 100644 index 00000000000..0d38ad5f19b --- /dev/null +++ b/packages/uhk-common/src/log/escape-zephyr-control-chars.ts @@ -0,0 +1,24 @@ +/** + * Render a chunk so every control/whitespace byte is visible in the log. Printable chars pass + * through; common controls get named escapes (\r \n \t \e), everything else control-range + * becomes \xNN, and the chunk is wrapped in ⟦…⟧ so leading/trailing spaces are obvious. + */ +export function escapeZephyrControlChars(input: string): string { + let out = ''; + for (const ch of input) { + const code = ch.charCodeAt(0); + switch (ch) { + case '\x1b': out += '\\e'; break; + case '\r': out += '\\r'; break; + case '\n': out += '\\n'; break; + case '\t': out += '\\t'; break; + case '\\': out += '\\\\'; break; + default: + out += code < 0x20 || code === 0x7f + ? '\\x' + code.toString(16).padStart(2, '0') + : ch; + } + } + + return out; +} diff --git a/packages/uhk-common/src/log/index.ts b/packages/uhk-common/src/log/index.ts index fe987c84a65..51bb719bf55 100644 --- a/packages/uhk-common/src/log/index.ts +++ b/packages/uhk-common/src/log/index.ts @@ -1,4 +1,5 @@ export * from './default-log-options.js'; +export * from './escape-zephyr-control-chars.js'; export * from './get-log-options.js'; export * from './log-reg-exps.js'; export * from './log-user-config-helper.js'; diff --git a/packages/uhk-common/src/util/ipcEvents.ts b/packages/uhk-common/src/util/ipcEvents.ts index 386a2cd303e..61916764982 100644 --- a/packages/uhk-common/src/util/ipcEvents.ts +++ b/packages/uhk-common/src/util/ipcEvents.ts @@ -31,6 +31,9 @@ export class Device { public static readonly dongleVersionInfoLoaded = 'device-dongle-version-info-loaded'; public static readonly eraseBleSettings = 'device-erase-ble-settings'; public static readonly eraseBleSettingsReply = 'device-erase-ble-settings-reply'; + public static readonly execShellCommandOnDongle = 'device-exec-shell-command-on-dongle'; + public static readonly execShellCommandOnLeftHalf = 'device-exec-shell-command-on-left-half'; + public static readonly execShellCommandOnRightHalf = 'device-exec-shell-command-on-right-half'; public static readonly hardwareModulesLoaded = 'device-hardware-modules-loaded'; public static readonly isDongleZephyrLoggingEnabled = 'device-is-dongle-zephyr-logging-enabled'; public static readonly isDongleZephyrLoggingEnabledReply = 'device-is-dongle-zephyr-logging-enabled-reply'; diff --git a/packages/uhk-usb/src/uhk-operations.ts b/packages/uhk-usb/src/uhk-operations.ts index 190b1e163f8..c26b1e0e812 100644 --- a/packages/uhk-usb/src/uhk-operations.ts +++ b/packages/uhk-usb/src/uhk-operations.ts @@ -747,7 +747,10 @@ export class UhkOperations { message += await this.getVariable(variableId, iteration + 1); } - if (iteration === 0) { + // The shell buffer carries a raw VT100 stream (colors, cursor control) that must be + // forwarded verbatim to the terminal emulator. Only the macro status buffer gets the + // dedup/reorder normalization. + if (iteration === 0 && variableId === UsbVariables.statusBuffer) { message = normalizeStatusBuffer(message); } @@ -936,4 +939,18 @@ export class UhkOperations { await this.device.write(buffer); } + + public async execShellCommand(cmd: string): Promise { + this.logService.usbOps('[DeviceOperation] USB[T]: Execute Shell Command'); + const b1 = Buffer.from([UsbCommand.ExecShellCommand]); + const b2 = Buffer.from(cmd); + const b0 = Buffer.from([0x00]); + const buffer = Buffer.concat([b1, b2, b0]); + + if (buffer.length > MAX_USB_PAYLOAD_SIZE) { + throw new Error('Shel command is too long. At most 61 characters are supported.') + } + + await this.device.write(buffer); + } } diff --git a/packages/uhk-web/angular.json b/packages/uhk-web/angular.json index d92fcdf7447..6b7e74a4fdd 100644 --- a/packages/uhk-web/angular.json +++ b/packages/uhk-web/angular.json @@ -32,6 +32,7 @@ ], "styles": [ "node_modules/nouislider/dist/nouislider.min.css", + "node_modules/@xterm/xterm/css/xterm.css", "node_modules/@perfectmemory/ngx-contextmenu/src/assets/stylesheets/base.scss", { "input": "src/styles.scss", @@ -136,6 +137,7 @@ ], "styles": [ "node_modules/nouislider/dist/nouislider.min.css", + "node_modules/@xterm/xterm/css/xterm.css", "node_modules/@perfectmemory/ngx-contextmenu/src/assets/stylesheets/base.scss", { "input": "src/styles.scss", diff --git a/packages/uhk-web/package-lock.json b/packages/uhk-web/package-lock.json index 3881c02fce0..17bba2ddd91 100644 --- a/packages/uhk-web/package-lock.json +++ b/packages/uhk-web/package-lock.json @@ -8,9 +8,6 @@ "name": "uhk-web", "version": "1.0.0", "license": "See in LICENSE", - "dependencies": { - "file-saver": "2.0.5" - }, "devDependencies": { "@angular-devkit/build-angular": "20.3.26", "@angular/animations": "20.3.21", @@ -39,12 +36,15 @@ "@ngrx/store": "20.1.0", "@ngrx/store-devtools": "20.1.0", "@perfectmemory/ngx-contextmenu": "20.0.0", + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", "angular-confirmation-popover": "7.0.0", "angular-eslint": "20.7.0", "angular-split": "20.0.0", "bootstrap": "5.3.8", "colord": "2.9.3", "dragula": "3.7.3", + "file-saver": "2.0.5", "gramli-angular-notifier": "18.0.0", "monaco-editor": "0.55.1", "naive-autocompletion-parser": "1.1.9", @@ -4849,9 +4849,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4869,9 +4866,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4889,9 +4883,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4909,9 +4900,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4929,9 +4917,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4949,9 +4934,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4969,9 +4951,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5578,9 +5557,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5602,9 +5578,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5626,9 +5599,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5650,9 +5620,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5674,9 +5641,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5698,9 +5662,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5904,9 +5865,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5921,9 +5879,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5938,9 +5893,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5955,9 +5907,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5972,9 +5921,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5989,9 +5935,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6006,9 +5949,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6023,9 +5963,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6040,9 +5977,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6057,9 +5991,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6074,9 +6005,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6091,9 +6019,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6108,9 +6033,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7072,6 +6994,23 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -9466,6 +9405,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "dev": true, "license": "MIT" }, "node_modules/fill-range": { diff --git a/packages/uhk-web/package.json b/packages/uhk-web/package.json index 593a4e8239c..11e98eb00e7 100644 --- a/packages/uhk-web/package.json +++ b/packages/uhk-web/package.json @@ -43,12 +43,15 @@ "@ngrx/store": "20.1.0", "@ngrx/store-devtools": "20.1.0", "@perfectmemory/ngx-contextmenu": "20.0.0", + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", "angular-confirmation-popover": "7.0.0", "angular-eslint": "20.7.0", "angular-split": "20.0.0", "bootstrap": "5.3.8", "colord": "2.9.3", "dragula": "3.7.3", + "file-saver": "2.0.5", "gramli-angular-notifier": "18.0.0", "monaco-editor": "0.55.1", "naive-autocompletion-parser": "1.1.9", @@ -63,8 +66,5 @@ "uhk-common": "file:../uhk-common", "xml-loader": "1.2.1", "zone.js": "0.15.1" - }, - "dependencies": { - "file-saver": "2.0.5" } } diff --git a/packages/uhk-web/src/app/components/device/advanced-settings/advanced-settings.page.component.html b/packages/uhk-web/src/app/components/device/advanced-settings/advanced-settings.page.component.html index e713b44383d..e4b0192d36b 100644 --- a/packages/uhk-web/src/app/components/device/advanced-settings/advanced-settings.page.component.html +++ b/packages/uhk-web/src/app/components/device/advanced-settings/advanced-settings.page.component.html @@ -79,7 +79,7 @@

-
- + @if (state.activeButton === ActiveButton.ShowZephyrLogs) { + + @if (state.isLeftHalfZephyrLoggingEnabled) { + + + + } + + @if (state.isRightHalfZephyrLoggingEnabled) { + + + + } + + @if (state.isDongleZephyrLoggingEnabled) { + + + + } + + } + @else + { + + }
+ diff --git a/packages/uhk-web/src/app/components/device/advanced-settings/advanced-settings.page.component.ts b/packages/uhk-web/src/app/components/device/advanced-settings/advanced-settings.page.component.ts index e23ae5b48d3..334696be7bd 100644 --- a/packages/uhk-web/src/app/components/device/advanced-settings/advanced-settings.page.component.ts +++ b/packages/uhk-web/src/app/components/device/advanced-settings/advanced-settings.page.component.ts @@ -10,7 +10,15 @@ import { import { faCog } from '@fortawesome/free-solid-svg-icons'; import { Store } from '@ngrx/store'; import { Observable, Subscription } from 'rxjs'; -import { KeyboardLayout, UHK_60_DEVICE, UHK_60_V2_DEVICE, UHK_80_DEVICE } from 'uhk-common'; +import { + KeyboardLayout, + UHK_60_DEVICE, + UHK_60_V2_DEVICE, + UHK_80_DEVICE, + UHK_80_DEVICE_LEFT, + UHK_DONGLE, + UhkDeviceProduct, +} from 'uhk-common'; import { advanceSettingsState, AppState, @@ -30,7 +38,9 @@ import { ToggleRightHalfZephyrLoggingAction, ToggleZephyrLoggingAction, } from '../../../store/actions/advance-settings.action'; -import { ChangeKeyboardLayoutAction } from '../../../store/actions/device'; +import { + ChangeKeyboardLayoutAction, +} from '../../../store/actions/device'; import { ActiveButton, initialState, State } from '../../../store/reducers/advanced-settings.reducer'; @Component({ @@ -48,16 +58,20 @@ export class AdvancedSettingsPageComponent implements OnInit, OnDestroy { @ViewChild('audioPlayer', {static: true,}) audioPlayer: ElementRef; + connectedDevice: UhkDeviceProduct; isKeyboardLayoutChanging$: Observable; isHalvesPairingAllowed: boolean; isZephyrLoggingAllowed: boolean; keyboardLayout: KeyboardLayout; keyboardLayoutEnum = KeyboardLayout; - showDongleZephyrLogCheckbox: boolean; + isDongleConnected: boolean; showI2CRecoverButton: boolean; - showLeftHalfZephyrLogCheckbox: boolean; + isLeftHalfConnected: boolean; state: State; + UHK_DONGLE = UHK_DONGLE; + UHK_80_DEVICE_LEFT = UHK_80_DEVICE_LEFT; + private i2cErrorsLength = 0; private stateSubscription: Subscription; private connectedDeviceSubscription: Subscription; @@ -86,6 +100,7 @@ export class AdvancedSettingsPageComponent implements OnInit, OnDestroy { ngOnInit(): void { this.connectedDeviceSubscription = this.store.select(getConnectedDevice) .subscribe(connectedDevice => { + this.connectedDevice = connectedDevice; this.isHalvesPairingAllowed = connectedDevice?.id === UHK_80_DEVICE.id; this.isZephyrLoggingAllowed = !!connectedDevice; this.showI2CRecoverButton = connectedDevice?.id === UHK_60_DEVICE.id || connectedDevice?.id === UHK_60_V2_DEVICE.id; @@ -93,7 +108,7 @@ export class AdvancedSettingsPageComponent implements OnInit, OnDestroy { }); this.dongleSubscription = this.store.select(getDongle) .subscribe(dongle => { - this.showDongleZephyrLogCheckbox = !!dongle.serialNumber; + this.isDongleConnected = !!dongle?.serialNumber; this.cdRef.detectChanges(); }) this.keyboardLayoutSubscription = this.store.select(getKeyboardLayout) @@ -103,7 +118,7 @@ export class AdvancedSettingsPageComponent implements OnInit, OnDestroy { }); this.leftHalfDetectedSubscription = this.store.select(getLeftHalfDetected) .subscribe(leftHalfDetected => { - this.showLeftHalfZephyrLogCheckbox = leftHalfDetected; + this.isLeftHalfConnected = leftHalfDetected; this.cdRef.detectChanges(); }); this.stateSubscription = this.store.select(advanceSettingsState) diff --git a/packages/uhk-web/src/app/components/xterm/xterm.component.html b/packages/uhk-web/src/app/components/xterm/xterm.component.html index 899419a3da8..00fb8a91f69 100644 --- a/packages/uhk-web/src/app/components/xterm/xterm.component.html +++ b/packages/uhk-web/src/app/components/xterm/xterm.component.html @@ -2,7 +2,7 @@
  • - +
    
                 
diff --git a/packages/uhk-web/src/app/components/xterm/xterm.component.scss b/packages/uhk-web/src/app/components/xterm/xterm.component.scss index 7af08640592..5175cb8d3e6 100644 --- a/packages/uhk-web/src/app/components/xterm/xterm.component.scss +++ b/packages/uhk-web/src/app/components/xterm/xterm.component.scss @@ -46,8 +46,12 @@ ul { li { padding-left: 5px; - span:before { - content: '$ '; + pre { + white-space: wrap; + margin: 0; + &:before { + content: '$ '; + } } } } diff --git a/packages/uhk-web/src/app/components/zephyr-terminal/zephyr-terminal.component.html b/packages/uhk-web/src/app/components/zephyr-terminal/zephyr-terminal.component.html new file mode 100644 index 00000000000..a6f58025102 --- /dev/null +++ b/packages/uhk-web/src/app/components/zephyr-terminal/zephyr-terminal.component.html @@ -0,0 +1,8 @@ +
+
+
+
+
diff --git a/packages/uhk-web/src/app/components/zephyr-terminal/zephyr-terminal.component.scss b/packages/uhk-web/src/app/components/zephyr-terminal/zephyr-terminal.component.scss new file mode 100644 index 00000000000..3372107cd6c --- /dev/null +++ b/packages/uhk-web/src/app/components/zephyr-terminal/zephyr-terminal.component.scss @@ -0,0 +1,31 @@ +:host { + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + height: 100%; +} + +.zephyr-term-container { + display: flex; + flex: 1; + flex-direction: column; + align-items: stretch; + position: relative; +} + +.zephyr-term-wrapper { + background-color: var(--color-xterm-bg); + border: 1px solid var(--color-xterm-border); + overflow: auto; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.zephyr-terminal { + width: 100%; + height: 100%; +} diff --git a/packages/uhk-web/src/app/components/zephyr-terminal/zephyr-terminal.component.ts b/packages/uhk-web/src/app/components/zephyr-terminal/zephyr-terminal.component.ts new file mode 100644 index 00000000000..af0e70d6b4e --- /dev/null +++ b/packages/uhk-web/src/app/components/zephyr-terminal/zephyr-terminal.component.ts @@ -0,0 +1,149 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import { Actions, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { FitAddon } from '@xterm/addon-fit'; +import { Terminal } from '@xterm/xterm'; +import { Subscription } from 'rxjs'; +import { + UhkDeviceProduct, + UHK_DEVICE_IDS, + ZephyrLogEntry, +} from 'uhk-common'; + +import { AppState } from '../../store'; +import { ActionTypes as AdvancedSettingsActionTypes, ZephyrLogAction } from '../../store/actions/advance-settings.action'; +import { + ExecShellCommandOnDongleAction, + ExecShellCommandOnLeftHalfAction, + ExecShellCommandOnRightHalfAction, +} from '../../store/actions/device'; + +/** + * Output: the raw shell buffer chunks emitted by the right-half poller (device === UHK 80 right) + * are written verbatim to xterm.js, so colors and cursor control render. + * + * Input: every keystroke (incl. ESC sequences for arrows, Tab, Ctrl-C, Enter) is forwarded to the + * device through the ExecShellCommand byte channel. Echo and history are produced by the firmware's + * shell, so they only light up once the firmware injects these bytes into the interactive shell + * input. NUL (0x00) is the only byte the transport can't carry, and VT100 treats NUL as ignorable + * fill, so nothing meaningful is lost. + */ +@Component({ + selector: 'zephyr-terminal', + standalone: false, + templateUrl: './zephyr-terminal.component.html', + styleUrls: ['./zephyr-terminal.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ZephyrTerminalComponent implements AfterViewInit, OnChanges, OnDestroy { + @Input() uhkDevice: UhkDeviceProduct; + @Input() zephyrLogs: ZephyrLogEntry[] = []; + + @ViewChild('terminal', { static: true }) terminalElement: ElementRef; + + private isLogRestored = false; + private terminal: Terminal; + private fitAddon: FitAddon; + private logSubscription: Subscription; + + constructor( + private store: Store, + private actions$: Actions, + ) {} + + ngAfterViewInit(): void { + this.terminal = new Terminal({ + convertEol: false, + fontFamily: 'JetBrains Mono', + cursorBlink: true, + scrollback: 10000, + }); + this.fitAddon = new FitAddon(); + this.terminal.loadAddon(this.fitAddon); + this.terminal.open(this.terminalElement.nativeElement); + setTimeout(() => { + this.fitAddon.fit(); + }, 1); + + // Forward every keystroke (raw bytes, incl. ESC sequences). + this.terminal.onData((data: string) => { + this.dispatchTerminalInput(data); + }); + + this.restoreLogHistory(); + + // Write raw shell output coming from the right half straight into the terminal. + this.logSubscription = this.actions$ + .pipe(ofType(AdvancedSettingsActionTypes.zephyrLog)) + .subscribe((action: ZephyrLogAction) => { + if (action.payload.device === this.uhkDevice?.logName) { + this.terminal.write(action.payload.log); + } + }); + } + + ngOnChanges(changes: SimpleChanges) { + if(changes.zephyrLogs?.firstChange) { + this.restoreLogHistory(); + } + } + + ngOnDestroy(): void { + this.logSubscription?.unsubscribe(); + this.terminal?.dispose(); + } + + onResized() { + this.fitAddon?.fit() + } + + private dispatchTerminalInput(data: string): void { + switch (this.uhkDevice?.id) { + case UHK_DEVICE_IDS.UHK_DONGLE: { + this.store.dispatch(new ExecShellCommandOnDongleAction(data)); + break; + } + + case UHK_DEVICE_IDS.UHK80_LEFT: { + this.store.dispatch(new ExecShellCommandOnLeftHalfAction(data)); + break; + } + + case UHK_DEVICE_IDS.UHK80_RIGHT: { + this.store.dispatch(new ExecShellCommandOnRightHalfAction(data)); + break; + } + } + } + + private restoreLogHistory(): void { + if (this.terminal && this.uhkDevice && !this.isLogRestored) { + this.isLogRestored = true; + + let foundHistory = false; + + for (const log of this.zephyrLogs) { + if (log.device === this.uhkDevice.logName) { + this.terminal.write(log.log); + foundHistory = true; + } + } + + // initialize the terminal if no history found, + // it will write the correct prompt + if (!foundHistory) { + this.dispatchTerminalInput('\n') + } + } + } +} diff --git a/packages/uhk-web/src/app/services/device-renderer.service.ts b/packages/uhk-web/src/app/services/device-renderer.service.ts index a3ff02a0f27..f205e6d0fff 100644 --- a/packages/uhk-web/src/app/services/device-renderer.service.ts +++ b/packages/uhk-web/src/app/services/device-renderer.service.ts @@ -104,6 +104,18 @@ export class DeviceRendererService { this.ipcRenderer.send(IpcEvents.device.eraseBleSettings); } + execShellCommandOnDongle(command: string): void { + this.ipcRenderer.send(IpcEvents.device.execShellCommandOnDongle, command); + } + + execShellCommandOnLeftHalf(command: string): void { + this.ipcRenderer.send(IpcEvents.device.execShellCommandOnLeftHalf, command); + } + + execShellCommandOnRightHalf(command: string): void { + this.ipcRenderer.send(IpcEvents.device.execShellCommandOnRightHalf, command); + } + isDongleZephyrLoggingEnabled(): void { this.ipcRenderer.send(IpcEvents.device.isDongleZephyrLoggingEnabled); } diff --git a/packages/uhk-web/src/app/shared.module.ts b/packages/uhk-web/src/app/shared.module.ts index a4fbbe3bf1f..e2c7d547446 100644 --- a/packages/uhk-web/src/app/shared.module.ts +++ b/packages/uhk-web/src/app/shared.module.ts @@ -147,6 +147,7 @@ import { UpdateAgentPageComponent } from './pages/update-agent.page'; import { UpdateFirmwarePageComponent } from './pages/update-firmware.page'; import { UhkDeviceLoadingGuard } from './services/uhk-device-loading.guard'; import { XtermComponent } from './components/xterm/xterm.component'; +import { ZephyrTerminalComponent } from './components/zephyr-terminal/zephyr-terminal.component'; import { SliderWrapperComponent } from './components/slider-wrapper/slider-wrapper.component'; import { EditableTextComponent } from './components/editable-text/editable-text.component'; import { Autofocus } from './directives/autofocus/autofocus.directive'; @@ -271,6 +272,7 @@ import appInitFactory from './services/app-init-factory'; UpdateAgentPageComponent, UpdateFirmwarePageComponent, XtermComponent, + ZephyrTerminalComponent, SliderWrapperComponent, EditableTextComponent, Autofocus, diff --git a/packages/uhk-web/src/app/store/actions/device.ts b/packages/uhk-web/src/app/store/actions/device.ts index ae296b84a02..df0d8035ec8 100644 --- a/packages/uhk-web/src/app/store/actions/device.ts +++ b/packages/uhk-web/src/app/store/actions/device.ts @@ -30,6 +30,9 @@ export enum ActionTypes { DongleVersionInfoLoaded = '[device] dongle version info loaded', EraseBleSettings = '[device] erase ble settings', EraseBleSettingsReply = '[device] erase ble settings reply', + ExecShellCommandOnDongle = '[device] exec shell command on dongle', + ExecShellCommandOnLeftHalf = '[device] exec shell command on left half', + ExecShellCommandOnRightHalf = '[device] exec shell command on right half', SetPrivilegeOnLinux = '[device] set privilege on linux', SetPrivilegeOnLinuxReply = '[device] set privilege on linux reply', ConnectionStateChanged = '[device] connection state changed', @@ -112,6 +115,24 @@ export class EraseBleSettingReplyAction implements Action { constructor(public payload: IpcResponse) {} } +export class ExecShellCommandOnDongleAction implements Action { + type = ActionTypes.ExecShellCommandOnDongle; + + constructor(public payload: string) {} +} + +export class ExecShellCommandOnLeftHalfAction implements Action { + type = ActionTypes.ExecShellCommandOnLeftHalf; + + constructor(public payload: string) {} +} + +export class ExecShellCommandOnRightHalfAction implements Action { + type = ActionTypes.ExecShellCommandOnRightHalf; + + constructor(public payload: string) {} +} + export class SetPrivilegeOnLinuxAction implements Action { type = ActionTypes.SetPrivilegeOnLinux; } @@ -328,6 +349,9 @@ export type Actions | DongleVersionInfoLoadedAction | EraseBleSettingAction | EraseBleSettingReplyAction + | ExecShellCommandOnDongleAction + | ExecShellCommandOnLeftHalfAction + | ExecShellCommandOnRightHalfAction | SetPrivilegeOnLinuxAction | SetPrivilegeOnLinuxReplyAction | ConnectionStateChangedAction diff --git a/packages/uhk-web/src/app/store/effects/device.ts b/packages/uhk-web/src/app/store/effects/device.ts index 44c110bd149..03d25fd5ee7 100644 --- a/packages/uhk-web/src/app/store/effects/device.ts +++ b/packages/uhk-web/src/app/store/effects/device.ts @@ -31,6 +31,9 @@ import { ConnectionStateChangedAction, EnableUsbStackTestAction, EraseBleSettingReplyAction, + ExecShellCommandOnDongleAction, + ExecShellCommandOnLeftHalfAction, + ExecShellCommandOnRightHalfAction, HideSaveToKeyboardButton, ReadConfigSizesAction, RecoveryDeviceAction, @@ -272,6 +275,36 @@ export class DeviceEffects { ), ); + execShellCommandOnDongle$ = createEffect(() => this.actions$ + .pipe( + ofType(ActionTypes.ExecShellCommandOnDongle), + tap((action) => { + this.deviceRendererService.execShellCommandOnDongle(action.payload); + }) + ), + { dispatch: false } + ); + + execShellCommandOnLeftHalf$ = createEffect(() => this.actions$ + .pipe( + ofType(ActionTypes.ExecShellCommandOnLeftHalf), + tap((action) => { + this.deviceRendererService.execShellCommandOnLeftHalf(action.payload); + }) + ), + { dispatch: false } + ); + + execShellCommandOnRightHalf$ = createEffect(() => this.actions$ + .pipe( + ofType(ActionTypes.ExecShellCommandOnRightHalf), + tap((action) => { + this.deviceRendererService.execShellCommandOnRightHalf(action.payload); + }) + ), + { dispatch: false } + ); + setPrivilegeOnLinux$ = createEffect(() => this.actions$ .pipe( ofType(ActionTypes.SetPrivilegeOnLinux), diff --git a/packages/uhk-web/src/app/store/reducers/advanced-settings.reducer.ts b/packages/uhk-web/src/app/store/reducers/advanced-settings.reducer.ts index 4b597e9fba5..163959c4da6 100644 --- a/packages/uhk-web/src/app/store/reducers/advanced-settings.reducer.ts +++ b/packages/uhk-web/src/app/store/reducers/advanced-settings.reducer.ts @@ -1,4 +1,4 @@ -import { getFormattedTimestamp, UhkDeviceProduct } from 'uhk-common'; +import { getFormattedTimestamp, UhkDeviceProduct, ZephyrLogEntry } from 'uhk-common'; import { XtermCssClass, XtermLog } from '../../models/xterm-log'; import { appendXtermLogs } from '../../util/merge-xterm-logs'; @@ -31,6 +31,7 @@ export interface State { isRightHalfZephyrLoggingEnabled: boolean; lastConnectedDevice?: UhkDeviceProduct; menuVisible: boolean; + zephyrLogs: ZephyrLogEntry[]; } export const initialState = (): State => ({ @@ -42,6 +43,7 @@ export const initialState = (): State => ({ isRightHalfZephyrLoggingEnabled: false, isLeftHalfPairing: false, menuVisible: false, + zephyrLogs: [], }); export function reducer(state = initialState(), action: Actions | App.Actions | Device.Actions) { @@ -199,7 +201,6 @@ export function reducer(state = initialState(), action: Actions | App.Actions | case ActionTypes.toggleZephyrLogging: { return { ...state, - i2cLogs: [], activeButton: state.activeButton === ActiveButton.ShowZephyrLogs ? ActiveButton.None : ActiveButton.ShowZephyrLogs, @@ -219,13 +220,15 @@ export function reducer(state = initialState(), action: Actions | App.Actions | const payload = (action as ZephyrLogAction).payload; const newState = {...state}; - newState.i2cLogs = [ - ...state.i2cLogs, - { - message: `${getFormattedTimestamp()} | ${payload.device.padEnd(15 )} | ${payload.log}`, - cssClass: payload.level === 'error' ? XtermCssClass.error : XtermCssClass.standard, - } - ]; + // the clear command sent so have to clear the history too + if (payload.log.startsWith('\\r\\n\\033[H\\033[2J\u001b')) { + newState.zephyrLogs.filter(log => log.device !== payload.device); + } + else { + newState.zephyrLogs = [...state.zephyrLogs]; + } + + newState.zephyrLogs.push(payload); return newState; }