From 14ef1c8a555e6e901dbd4067464e07f6243374fc Mon Sep 17 00:00:00 2001 From: ryanmelt-agent Date: Thu, 14 May 2026 19:20:04 +0000 Subject: [PATCH 1/2] feat(cmdsender): file upload support for BLOCK params and Send Raw File Adds two CmdSender enhancements requested in OpenC3/cosmos#59: - Right-clicking a BLOCK parameter row in the Command Editor now offers an "Upload Data" entry. Selecting it opens a file picker, reads the file as bytes, and fills the parameter with the matching 0x... hex string that convertToValue already understands for BLOCK fields. - The previously stubbed "Send Raw File" menu option under File is now wired up: it fetches the available interfaces, prompts for an interface and file, and ships the bytes via OpenC3Api.send_raw. Includes a parallel Playwright test covering the BLOCK upload path against INST MEMLOAD and the Send Raw dialog open/cancel flow. Fixes #59 Co-Authored-By: Paperclip --- .../src/tools/CommandSender/CommandSender.vue | 161 ++++++++++-------- .../src/components/CommandEditor.vue | 67 +++++++- playwright/tests/command-sender.p.spec.ts | 59 +++++++ 3 files changed, 206 insertions(+), 81 deletions(-) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-cmdsender/src/tools/CommandSender/CommandSender.vue b/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-cmdsender/src/tools/CommandSender/CommandSender.vue index bcf8cab737..791c60cd4f 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-cmdsender/src/tools/CommandSender/CommandSender.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-cmdsender/src/tools/CommandSender/CommandSender.vue @@ -176,7 +176,11 @@ Filename: - + @@ -265,18 +269,17 @@ export default { computed: { menus: function () { return [ - // TODO: Implement send raw - // { - // label: 'File', - // items: [ - // { - // label: 'Send Raw', - // command: () => { - // this.setupRawCmd() - // }, - // }, - // ], - // }, + { + label: 'File', + items: [ + { + label: 'Send Raw File', + command: () => { + this.setupRawCmd() + }, + }, + ], + }, { label: 'Mode', items: [ @@ -863,71 +866,81 @@ export default { }) }, - // setupRawCmd() { - // this.api.get_interface_names().then( - // (response) => { - // let interfaces = [] - // for (let i = 0; i < response.length; i++) { - // interfaces.push({ label: response[i], value: response[i] }) - // } - // this.interfaces = interfaces - // this.selectedInterface = interfaces[0].value - // this.displaySendRaw = true - // }, - // (error) => { - // this.displaySendRaw = false - // this.displayError('getting interface names', error, true) - // } - // ) - // }, - - // selectRawCmdFile(event) { - // this.rawCmdFile = event.target.files[0] - // }, + setupRawCmd() { + this.api.get_interface_names().then( + (response) => { + let interfaces = [] + for (let i = 0; i < response.length; i++) { + interfaces.push({ label: response[i], value: response[i] }) + } + this.interfaces = interfaces + this.selectedInterface = interfaces.length ? interfaces[0].value : '' + this.rawCmdFile = null + this.displaySendRaw = true + }, + (error) => { + this.displaySendRaw = false + this.displayError('getting interface names', error, true) + }, + ) + }, - // onLoad(event) { - // let bufView = new Uint8Array(event.target.result) - // let jstr = { json_class: 'String', raw: [] } - // for (let i = 0; i < bufView.length; i++) { - // jstr.raw.push(bufView[i]) - // } + selectRawCmdFile(event) { + this.rawCmdFile = event.target.files[0] + }, - // this.api.send_raw(this.selectedInterface, jstr).then( - // () => { - // this.displaySendRaw = false - // this.status = - // 'Sent ' + - // bufView.length + - // ' bytes to interface ' + - // this.selectedInterface - // }, - // (error) => { - // this.displaySendRaw = false - // this.displayError('sending raw data', error, true) - // } - // ) - // }, + onLoad(event) { + let bufView = new Uint8Array(event.target.result) + let jstr = { json_class: 'String', raw: [] } + for (let i = 0; i < bufView.length; i++) { + jstr.raw.push(bufView[i]) + } - // sendRawCmd() { - // let self = this - // let reader = new FileReader() - // reader.onload = function (e) { - // self.onLoad(e) - // } - // reader.onerror = function (e) { - // self.displaySendRaw = false - // let target = e.target - // self.displayError('sending raw data', target.error, true) - // } - // // TBD - use the other event handlers to implement a progress bar for the - // // file upload. Handle abort as well? - // //reader.onloadstart = function(e) {} - // //reader.onprogress = function(e) {} - // //reader.onloadend = function(e) {} - // //reader.onabort = function(e) {} + this.api.send_raw(this.selectedInterface, jstr).then( + () => { + this.displaySendRaw = false + this.status = + 'Sent ' + + bufView.length + + ' bytes to interface ' + + this.selectedInterface + }, + (error) => { + this.displaySendRaw = false + this.displayError('sending raw data', error, true) + }, + ) + }, - // reader.readAsArrayBuffer(this.rawCmdFile) - // }, + sendRawCmd() { + if (!this.selectedInterface) { + this.displayError( + 'sending raw data', + { name: 'Error', message: 'No interface selected' }, + true, + ) + return + } + if (!this.rawCmdFile) { + this.displayError( + 'sending raw data', + { name: 'Error', message: 'No file selected' }, + true, + ) + return + } + let self = this + let reader = new FileReader() + reader.onload = function (e) { + self.onLoad(e) + } + reader.onerror = function (e) { + self.displaySendRaw = false + let target = e.target + self.displayError('sending raw data', target.error, true) + } + reader.readAsArrayBuffer(this.rawCmdFile) + }, cancelRawCmd() { this.displaySendRaw = false diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/CommandEditor.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/CommandEditor.vue index 6d214efe5c..eff022ea30 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/CommandEditor.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/CommandEditor.vue @@ -80,6 +80,7 @@ {{ item.title }} @@ -87,6 +88,14 @@ + + v === true) + }, + contextMenuOptions() { + const options = [ { title: 'Details', + dataTest: 'cmd-param-details', action: () => { this.contextMenuShown = false this.viewDetails = true }, }, - ], - } - }, - computed: { - hasHazardousParameter() { - return Object.values(this.hazardousParameters).some((v) => v === true) + ] + if (this.selectedRow && this.selectedRow.type === 'BLOCK') { + options.push({ + title: 'Upload Data', + dataTest: 'cmd-param-upload-data', + action: () => { + this.contextMenuShown = false + this.openBlockUploadPicker() + }, + }) + } + return options }, }, created() { @@ -232,6 +256,7 @@ export default { showContextMenu(event, row) { event.preventDefault() this.parameterName = row.item.parameter_name + this.selectedRow = row.item this.contextMenuShown = false this.x = event.clientX this.y = event.clientY @@ -239,6 +264,34 @@ export default { this.contextMenuShown = true }) }, + openBlockUploadPicker() { + const input = this.$refs.blockUploadInput + if (!input) return + // Reset so re-selecting the same file still fires @change + input.value = '' + input.click() + }, + onBlockUploadFile(event) { + const file = event.target.files && event.target.files[0] + if (!file || !this.selectedRow) return + const targetRow = this.selectedRow + const reader = new FileReader() + reader.onload = (e) => { + const bytes = new Uint8Array(e.target.result) + let hex = '0x' + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0').toUpperCase() + } + // Setting val on the row updates the bound CommandParameterEditor. + // The "0x..." prefix lets convertToValue parse it as a binary BLOCK. + targetRow.val = hex + } + reader.onerror = () => { + // eslint-disable-next-line no-console + console.error('Error reading uploaded file:', reader.error) + } + reader.readAsArrayBuffer(file) + }, updateCmdParams() { this.ignoredParams = [] this.computedRows = [] diff --git a/playwright/tests/command-sender.p.spec.ts b/playwright/tests/command-sender.p.spec.ts index ef83dafd13..dafa99f8d3 100644 --- a/playwright/tests/command-sender.p.spec.ts +++ b/playwright/tests/command-sender.p.spec.ts @@ -760,3 +760,62 @@ test('sends manually entered state values', async ({ page, utils }) => { // Close the dropdown by pressing Escape await page.keyboard.press('Escape') }) + +test('uploads data into a BLOCK parameter via right-click', async ({ + page, + utils, +}) => { + await page.locator('[data-test="clear-history"]').click() + // INST MEMLOAD has a single BLOCK parameter named DATA + await utils.selectTargetPacketItem('INST', 'MEMLOAD') + await expect(page.locator('main')).toContainText('Parameters') + + const dataRow = page.locator('tr:has(td:text-is("DATA"))') + + // Set up the file chooser before right-clicking so the picker is captured + const fileChooserPromise = page.waitForEvent('filechooser') + + // Right-click on the DATA row to open the context menu + await dataRow.click({ button: 'right' }) + // The new Upload Data entry should be present for BLOCK parameters + const uploadItem = page.locator('[data-test=cmd-param-upload-data]') + await expect(uploadItem).toBeVisible() + await uploadItem.click() + + // Provide a small payload via the file chooser + const fileChooser = await fileChooserPromise + await fileChooser.setFiles({ + name: 'upload.bin', + mimeType: 'application/octet-stream', + buffer: Buffer.from([0xde, 0xad, 0xbe, 0xef]), + }) + + // The BLOCK text field should now show the hex representation + await expect( + dataRow.locator('[data-test=cmd-param-value] input').first(), + ).toHaveValue('0xDEADBEEF') + + // Non-BLOCK parameters should not see Upload Data + await utils.selectTargetPacketItem('INST', 'COLLECT') + await page.locator('tr:has(td:text-is("DURATION"))').click({ button: 'right' }) + await expect( + page.locator('[data-test=cmd-param-upload-data]'), + ).not.toBeVisible() + await page.keyboard.press('Escape') +}) + +test('opens the Send Raw File dialog and lists interfaces', async ({ + page, +}) => { + // Open File > Send Raw File from the top bar + await page.locator('[data-test=command-sender-file]').click() + await page.locator('[data-test=command-sender-file-send-raw-file]').click() + + // Dialog should be visible and show the interface picker + await expect(page.getByText('Send Raw')).toBeVisible() + await expect(page.locator('[data-test=send-raw-file]')).toBeVisible() + + // Cancel and confirm status is updated + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(page.locator('main')).toContainText('Raw command not sent') +}) From f12f03b5e2485b23d71db5efdb4839ee8f4071d7 Mon Sep 17 00:00:00 2001 From: ryanmelt-agent Date: Thu, 14 May 2026 21:37:03 +0000 Subject: [PATCH 2/2] refactor(cmdsender): address SonarQube findings on PR #3375 Replace FileReader + index-style for loops in the new BLOCK upload and Send Raw File paths with Blob#arrayBuffer() and for-of iteration, and drop the obsolete `self = this` aliasing in favor of `async sendRawCmd`. - CommandEditor.onBlockUploadFile is now async and reads the file via await file.arrayBuffer(); the hex encode loop uses for-of. - CommandSender.setupRawCmd builds the interface list via Array#map. - CommandSender.sendRawCmd is now async, reads the file via Blob#arrayBuffer(), and calls send_raw inline; the helper onLoad and the FileReader pattern are removed entirely. Behavior is unchanged; only fixes the 6 SonarQube findings (javascript:S4138, javascript:S7740, javascript:S7756). Co-Authored-By: Paperclip --- .../src/tools/CommandSender/CommandSender.vue | 66 ++++++++----------- .../src/components/CommandEditor.vue | 18 +++-- 2 files changed, 36 insertions(+), 48 deletions(-) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-cmdsender/src/tools/CommandSender/CommandSender.vue b/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-cmdsender/src/tools/CommandSender/CommandSender.vue index 791c60cd4f..73e317d522 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-cmdsender/src/tools/CommandSender/CommandSender.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-cmdsender/src/tools/CommandSender/CommandSender.vue @@ -869,10 +869,10 @@ export default { setupRawCmd() { this.api.get_interface_names().then( (response) => { - let interfaces = [] - for (let i = 0; i < response.length; i++) { - interfaces.push({ label: response[i], value: response[i] }) - } + const interfaces = response.map((name) => ({ + label: name, + value: name, + })) this.interfaces = interfaces this.selectedInterface = interfaces.length ? interfaces[0].value : '' this.rawCmdFile = null @@ -889,30 +889,7 @@ export default { this.rawCmdFile = event.target.files[0] }, - onLoad(event) { - let bufView = new Uint8Array(event.target.result) - let jstr = { json_class: 'String', raw: [] } - for (let i = 0; i < bufView.length; i++) { - jstr.raw.push(bufView[i]) - } - - this.api.send_raw(this.selectedInterface, jstr).then( - () => { - this.displaySendRaw = false - this.status = - 'Sent ' + - bufView.length + - ' bytes to interface ' + - this.selectedInterface - }, - (error) => { - this.displaySendRaw = false - this.displayError('sending raw data', error, true) - }, - ) - }, - - sendRawCmd() { + async sendRawCmd() { if (!this.selectedInterface) { this.displayError( 'sending raw data', @@ -929,17 +906,30 @@ export default { ) return } - let self = this - let reader = new FileReader() - reader.onload = function (e) { - self.onLoad(e) - } - reader.onerror = function (e) { - self.displaySendRaw = false - let target = e.target - self.displayError('sending raw data', target.error, true) + let bufView + try { + const buffer = await this.rawCmdFile.arrayBuffer() + bufView = new Uint8Array(buffer) + } catch (err) { + this.displaySendRaw = false + this.displayError('sending raw data', err, true) + return } - reader.readAsArrayBuffer(this.rawCmdFile) + const jstr = { json_class: 'String', raw: Array.from(bufView) } + this.api.send_raw(this.selectedInterface, jstr).then( + () => { + this.displaySendRaw = false + this.status = + 'Sent ' + + bufView.length + + ' bytes to interface ' + + this.selectedInterface + }, + (error) => { + this.displaySendRaw = false + this.displayError('sending raw data', error, true) + }, + ) }, cancelRawCmd() { diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/CommandEditor.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/CommandEditor.vue index eff022ea30..40d8da0217 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/CommandEditor.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/components/CommandEditor.vue @@ -271,26 +271,24 @@ export default { input.value = '' input.click() }, - onBlockUploadFile(event) { + async onBlockUploadFile(event) { const file = event.target.files && event.target.files[0] if (!file || !this.selectedRow) return const targetRow = this.selectedRow - const reader = new FileReader() - reader.onload = (e) => { - const bytes = new Uint8Array(e.target.result) + try { + const buffer = await file.arrayBuffer() + const bytes = new Uint8Array(buffer) let hex = '0x' - for (let i = 0; i < bytes.length; i++) { - hex += bytes[i].toString(16).padStart(2, '0').toUpperCase() + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0').toUpperCase() } // Setting val on the row updates the bound CommandParameterEditor. // The "0x..." prefix lets convertToValue parse it as a binary BLOCK. targetRow.val = hex - } - reader.onerror = () => { + } catch (err) { // eslint-disable-next-line no-console - console.error('Error reading uploaded file:', reader.error) + console.error('Error reading uploaded file:', err) } - reader.readAsArrayBuffer(file) }, updateCmdParams() { this.ignoredParams = []