diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47bc1cd..f331bc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,8 @@ on: branches: - dev -# permissions: -# actions: read -# contents: read - jobs: - ci-renderer: + ci: runs-on: ubuntu-latest steps: - name: Check out Git repository @@ -29,10 +25,15 @@ jobs: node-version-file: '.nvmrc' cache: npm + - name: Add missing dependencies on act + if: ${{ env.ACT }} + run: apt-get update && apt-get install xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb -y + - name: Install dependencies run: npm ci - name: Derive appropriate SHAs for base and head for `nx affected` commands + if: ${{ !env.ACT }} uses: nrwl/nx-set-shas@v4 - name: Run CI on affected projects @@ -55,36 +56,3 @@ jobs: if: ${{ steps.nx-release.outputs.new_release_version != '' }} run: | echo "Version ${{ steps.nx-release.outputs.new_release_version }} will be released" >> $GITHUB_STEP_SUMMARY - - ci-electron: - needs: [ci-renderer] - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - - steps: - - name: Check out Git repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup NodeJS - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: npm - - - name: Install setuptools on macOS - if: matrix.os == 'macos-latest' - run: python3 -m pip install setuptools --break-system-packages - - - name: Install dependencies - run: npm ci - - - name: Build - run: npx nx run-many -t build --prod - - - name: Build unpacked executable - run: npx nx run spie:package diff --git a/.vscode/settings.json b/.vscode/settings.json index ef49842..4397974 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -42,7 +42,10 @@ // Eslint settings "eslint.useFlatConfig": true, - // Code spell checker serrings + // Code spell checker settings "cSpell.language": "en,en-AU", - "cSpell.words": ["Oliveira", "Scrollback", "SPIE"] + "cSpell.words": ["Oliveira", "Scrollback", "SPIE"], + + // Commitlint settings + "commitlint.preferBundledLibraries": true } diff --git a/apps/spie-ui-e2e/src/e2e/app.cy.ts b/apps/spie-ui-e2e/src/e2e/app.cy.ts deleted file mode 100644 index 701f4ac..0000000 --- a/apps/spie-ui-e2e/src/e2e/app.cy.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { ElectronAPI } from '@spie/types'; - -describe('Serial Port Configuration', () => { - let mockElectronAPI: ElectronAPI; - - beforeEach(() => { - mockElectronAPI = { - platform: '', - quit: cy.stub(), - getVersion: cy.stub().resolves('v1.0.0'), - downloadUpdate: cy.stub(), - installUpdate: cy.stub(), - onUpdateEvent: cy.stub(), - serialPort: { - list: cy.stub().resolves([ - { path: '/dev/ttyUSB0', manufacturer: 'Manufacturer1' }, - { path: '/dev/ttyUSB1', manufacturer: 'Manufacturer2' }, - ]), - open: cy.stub().resolves(), - close: cy.stub().resolves(), - isOpen: cy.stub().resolves(false), - write: cy.stub().resolves(true), - setReadEncoding: cy.stub().resolves(false), - onEvent: cy.stub(), - }, - }; - - cy.visit('/', { - onBeforeLoad(win) { - win.electron = mockElectronAPI; - }, - }); - }); - - it('should display available serial ports in the dropdown', () => { - cy.get('[data-cy="serial-port-select"]').click(); - - cy.get('ion-alert .alert-radio-button').should('have.length', 2); - cy.get('ion-alert .alert-radio-button') - .eq(0) - .should('contain.text', '/dev/ttyUSB0'); - cy.get('ion-alert .alert-radio-button') - .eq(1) - .should('contain.text', '/dev/ttyUSB1'); - }); - - it('should allow selecting a serial port', () => { - cy.get('[data-cy="serial-port-select"]').click(); - cy.get('ion-alert .alert-radio-button').eq(0).click(); - cy.get('ion-alert button.alert-button').contains('OK').click(); - - cy.get('[data-cy="serial-port-select"]') - .shadow() - .find('.select-text') - .should('contain', '/dev/ttyUSB0'); - }); - - it('should allow changing the baud rate', () => { - cy.get('[data-cy="baud-rate-select"]').click(); - cy.get('ion-alert .alert-radio-button').contains('115200').click(); - cy.get('ion-alert button.alert-button').contains('OK').click(); - - cy.get('[data-cy="baud-rate-select"]') - .shadow() - .find('.select-text') - .should('contain', '115200'); - }); - - it('should disable the Connect button when no serial port is selected', () => { - cy.get('[data-cy="connect-button"]').should( - 'have.class', - 'button-disabled' - ); - }); - - it('should enable the Connect button when a serial port is selected', () => { - cy.get('[data-cy="serial-port-select"]').click(); - cy.get('ion-alert .alert-radio-button').eq(0).click(); - cy.get('ion-alert button.alert-button').contains('OK').click(); - - cy.get('[data-cy="connect-button"]').should('not.be.disabled'); - cy.get('[data-cy="connect-button"]').should( - 'not.have.class', - 'button-disabled' - ); - }); -}); diff --git a/apps/spie-ui-e2e/src/e2e/send.cy.ts b/apps/spie-ui-e2e/src/e2e/send.cy.ts new file mode 100644 index 0000000..e91c231 --- /dev/null +++ b/apps/spie-ui-e2e/src/e2e/send.cy.ts @@ -0,0 +1,201 @@ +import { type SerialPortEvent } from '@spie/types'; + +import { mockElectronAPI } from '../fixtures/mocks/electron-api.mock'; + +describe('Send component', () => { + const mockSerialPortList = [ + { path: '/dev/ttyUSB0', manufacturer: 'Manufacturer1' }, + { path: '/dev/ttyUSB1', manufacturer: 'Manufacturer2' }, + ]; + + let onEventTrigger: ((event: SerialPortEvent) => void) | null; + + beforeEach(() => { + cy.visit('/'); + + cy.on('window:before:load', (win) => { + const listeners: Array<(serialPortEvent: SerialPortEvent) => void> = []; + + win.electron = mockElectronAPI(); + win.electron.serialPort.list = cy.stub().resolves(mockSerialPortList); + + win.electron.serialPort.onEvent = cy + .stub() + .callsFake((callback: (serialPortEvent: SerialPortEvent) => void) => { + listeners.push(callback); + + onEventTrigger = (serialPortEvent) => { + listeners.forEach((listener) => listener(serialPortEvent)); + }; + + return () => { + const index = listeners.indexOf(callback); + if (index !== -1) { + listeners.splice(index, 1); + } + }; + }); + }); + }); + + it('should enable/disable send based on serial port status', () => { + const data = 'test test test test test test test test test test'; + + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'open' }); + } + }); + + cy.get('app-send ion-input input').invoke('val', data).trigger('input'); + + cy.get('app-send ion-button') + .contains('Send') + .should('not.have.class', 'button-disabled'); + + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'close' }); + } + }); + + cy.get('app-send ion-button') + .contains('Send') + .should('have.class', 'button-disabled'); + }); + + it('should clear input after pressing clear input button', () => { + const data = 'test test test test test test test test test test'; + + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'open' }); + } + }); + + cy.get('app-send ion-input input').invoke('val', data).trigger('input'); + cy.get('app-send ion-input button').click(); + + cy.get('app-send ion-input input').should('have.value', ''); + }); + + it('should send input with default options', () => { + const data = 'test test test test test test test test test test'; + const formattedData = `${data}\n`; + + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'open' }); + } + }); + + cy.get('app-send ion-input input').invoke('val', data).trigger('input'); + + cy.get('app-send ion-button').contains('Send').click(); + + cy.window().then((win) => { + cy.wrap(win.electron.serialPort.write).should( + 'have.been.calledOnceWithExactly', + formattedData, + 'ascii' + ); + }); + }); + + it('should open and close the advanced modal', () => { + cy.get('app-send ion-button ion-icon').parent().click(); + cy.get('ion-modal').should('be.visible'); + cy.get('ion-modal ion-toolbar ion-button').click(); + cy.get('ion-modal').should('not.be.visible'); + }); + + it('should clear input after changing encoding', () => { + const data = 'test test test test test test test test test test'; + cy.get('app-send ion-input input').invoke('val', data).trigger('input'); + + cy.get('app-send ion-button ion-icon').parent().click(); + cy.getAdvancedModalSelectElement( + 'send-advanced-modal', + 'Encoding' + ).selectOption('Hex'); + cy.get('ion-modal ion-toolbar ion-button').click(); + + cy.get('app-send ion-input input').should('have.value', ''); + }); + + it('should format hex input', () => { + const data = 'test test test test test test test test test test'; + const expectedHexData = 'EE EE EE EE EE'; + + cy.get('app-send ion-button ion-icon').parent().click(); + cy.getAdvancedModalSelectElement( + 'send-advanced-modal', + 'Encoding' + ).selectOption('Hex'); + cy.get('ion-modal ion-toolbar ion-button').click(); + + cy.get('app-send ion-input input').invoke('val', data).trigger('input'); + + cy.get('app-send ion-input input').should('have.value', expectedHexData); + }); + + it('should send input with hex encoding', () => { + const data = 'test test test test test test test test test test\n\n\n'; + const formattedData = 'EEEEEEEEEE'; + + cy.get('app-send ion-button ion-icon').parent().click(); + cy.getAdvancedModalSelectElement( + 'send-advanced-modal', + 'Encoding' + ).selectOption('Hex'); + cy.get('ion-modal ion-toolbar ion-button').click(); + + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'open' }); + } + }); + + cy.get('app-send ion-input input').invoke('val', data).trigger('input'); + + cy.get('app-send ion-button').contains('Send').click(); + + cy.window().then((win) => { + cy.wrap(win.electron.serialPort.write).should( + 'have.been.calledOnceWithExactly', + formattedData, + 'hex' + ); + }); + }); + + it('should send input with advanced delimiter', () => { + const data = 'test test test test test test test test test test'; + const formattedData = `${data}\r\n`; + + cy.get('app-send ion-button ion-icon').parent().click(); + cy.getAdvancedModalSelectElement( + 'send-advanced-modal', + 'Delimiter' + ).selectOption('CRLF (\\r\\n)'); + cy.get('ion-modal ion-toolbar ion-button').click(); + + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'open' }); + } + }); + + cy.get('app-send ion-input input').invoke('val', data).trigger('input'); + + cy.get('app-send ion-button').contains('Send').click(); + + cy.window().then((win) => { + cy.wrap(win.electron.serialPort.write).should( + 'have.been.calledOnceWithExactly', + formattedData, + 'ascii' + ); + }); + }); +}); diff --git a/apps/spie-ui-e2e/src/e2e/serial-port.cy.ts b/apps/spie-ui-e2e/src/e2e/serial-port.cy.ts new file mode 100644 index 0000000..c437f87 --- /dev/null +++ b/apps/spie-ui-e2e/src/e2e/serial-port.cy.ts @@ -0,0 +1,207 @@ +import { type OpenOptions } from '@serialport/bindings-interface'; +import { type SerialPortEvent } from '@spie/types'; + +import { mockElectronAPI } from '../fixtures/mocks/electron-api.mock'; + +describe('Serial Port component', () => { + const mockSerialPortList = [ + { path: '/dev/ttyUSB0', manufacturer: 'Manufacturer1' }, + { path: '/dev/ttyUSB1', manufacturer: 'Manufacturer2' }, + ]; + + let onEventTrigger: ((event: SerialPortEvent) => void) | null; + + beforeEach(() => { + cy.visit('/'); + + cy.on('window:before:load', (win) => { + const listeners: Array<(serialPortEvent: SerialPortEvent) => void> = []; + + win.electron = mockElectronAPI(); + + win.electron.serialPort.list = cy.stub().resolves(mockSerialPortList); + win.electron.serialPort.onEvent = cy + .stub() + .callsFake((callback: (serialPortEvent: SerialPortEvent) => void) => { + listeners.push(callback); + + onEventTrigger = (serialPortEvent) => { + listeners.forEach((listener) => listener(serialPortEvent)); + }; + + return () => { + const index = listeners.indexOf(callback); + if (index !== -1) { + listeners.splice(index, 1); + } + }; + }); + }); + }); + + it('should display available serial ports in the dropdown', () => { + cy.get('app-connection [placeholder="Select Serial Port"]').click(); + cy.get('ion-alert .alert-radio-button').should( + 'have.length', + mockSerialPortList.length + ); + + mockSerialPortList.forEach((item, index) => { + cy.get('ion-alert .alert-radio-button') + .eq(index) + .should('contain.text', item.path); + }); + }); + + it('should allow selecting a serial port', () => { + const expectedPath = mockSerialPortList[0].path; + + cy.get('app-connection [placeholder="Select Serial Port"]').selectOption( + expectedPath + ); + + cy.get('app-connection [placeholder="Select Serial Port"]') + .shadow() + .find('.select-text') + .should('contain', expectedPath); + }); + + it('should allow selecting a baud rate', () => { + const expectedBaudRate = 115200; + + cy.get('app-connection [placeholder="Select Baud Rate"]').selectOption( + expectedBaudRate + ); + + cy.get('app-connection [placeholder="Select Baud Rate"]') + .shadow() + .find('.select-text') + .should('contain', expectedBaudRate); + }); + + it('should disable the Connect button when no serial port is selected', () => { + cy.get('app-connection ion-button') + .contains('Connect') + .should('have.class', 'button-disabled'); + }); + + it('should enable the Connect button after selecting a port', () => { + const expectedPath = mockSerialPortList[0].path; + + cy.get('app-connection [placeholder="Select Serial Port"]').selectOption( + expectedPath + ); + cy.get('app-connection ion-button') + .contains('Connect') + .should('not.have.class', 'button-disabled'); + }); + + it('should correctly call the IPC open and close methods', () => { + const openOptions: OpenOptions = { + path: mockSerialPortList[0].path, + baudRate: 9600, + }; + + cy.get('app-connection [placeholder="Select Serial Port"]').selectOption( + openOptions.path + ); + cy.get('app-connection [placeholder="Select Baud Rate"]').selectOption( + openOptions.baudRate + ); + + cy.get('app-connection ion-button').contains('Connect').click(); + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'open' }); + } + }); + cy.window().then((win) => { + cy.wrap(win.electron.serialPort.open).should( + 'have.been.calledOnceWith', + Cypress.sinon.match(openOptions) + ); + }); + cy.get('app-connection ion-button') + .contains('Disconnect') + .should('be.visible'); + + cy.get('app-connection ion-button').contains('Disconnect').click(); + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'close' }); + } + }); + cy.window().then((win) => { + cy.wrap(win.electron.serialPort.close).should('have.been.calledOnce'); + }); + cy.get('app-connection ion-button') + .contains('Connect') + .should('be.visible'); + }); + + it('should reconnect when baud rate changes', () => { + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'open' }); + } + }); + + cy.get('app-connection [placeholder="Select Baud Rate"]').selectOption( + 115200 + ); + + cy.window().then((win) => { + cy.wrap(win.electron.serialPort.open).should( + 'have.been.calledWith', + Cypress.sinon.match.has('baudRate', 115200) + ); + }); + + cy.get('app-connection ion-button') + .contains('Disconnect') + .should('be.visible'); + }); + + it('should open and close the advanced modal', () => { + cy.get('app-connection ion-button ion-icon').parent().click(); + cy.get('ion-modal').should('be.visible'); + cy.get('ion-modal ion-toolbar ion-button').click(); + cy.get('ion-modal').should('not.be.visible'); + }); + + it('should reconnect after changing advanced settings', () => { + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ event: 'open' }); + } + }); + + cy.get('app-connection ion-button ion-icon').parent().click(); + + cy.getAdvancedModalCheckboxElement( + 'connection-advanced-modal', + 'HUPCL' + ).click(); + + cy.getAdvancedModalSelectElement( + 'connection-advanced-modal', + 'Data Bits' + ).selectOption('5'); + + cy.window().then((win) => { + setTimeout(() => { + cy.wrap(win.electron.serialPort.close).should('have.been.calledTwice'); + cy.wrap(win.electron.serialPort.open).should( + 'have.been.calledWith', + Cypress.sinon.match.has('hupcl', false), + Cypress.sinon.match.has('dataBits', 5) + ); + cy.wrap(win.electron.serialPort.close).should('have.been.calledOnce'); + + cy.get('app-connection ion-button') + .contains('Disconnect') + .should('be.visible'); + }, 500); + }); + }); +}); diff --git a/apps/spie-ui-e2e/src/e2e/terminal.cy.ts b/apps/spie-ui-e2e/src/e2e/terminal.cy.ts new file mode 100644 index 0000000..81a3f6d --- /dev/null +++ b/apps/spie-ui-e2e/src/e2e/terminal.cy.ts @@ -0,0 +1,167 @@ +import { type SerialPortEvent } from '@spie/types'; + +import { mockElectronAPI } from '../fixtures/mocks/electron-api.mock'; + +describe('Terminal component', () => { + const mockSerialPortList = [ + { path: '/dev/ttyUSB0', manufacturer: 'Manufacturer1' }, + { path: '/dev/ttyUSB1', manufacturer: 'Manufacturer2' }, + ]; + + let onEventTrigger: ((event: SerialPortEvent) => void) | null; + + beforeEach(() => { + cy.visit('/'); + + cy.on('window:before:load', (win) => { + const listeners: Array<(serialPortEvent: SerialPortEvent) => void> = []; + + win.electron = mockElectronAPI(); + win.electron.serialPort.list = cy.stub().resolves(mockSerialPortList); + + win.electron.serialPort.onEvent = cy + .stub() + .callsFake((callback: (serialPortEvent: SerialPortEvent) => void) => { + listeners.push(callback); + + onEventTrigger = (serialPortEvent) => { + listeners.forEach((listener) => listener(serialPortEvent)); + }; + + return () => { + const index = listeners.indexOf(callback); + if (index !== -1) { + listeners.splice(index, 1); + } + }; + }); + }); + }); + + it('should display data on the terminal', () => { + const data = 'test\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\n'; + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ + event: 'data', + data: data, + }); + } + }); + + cy.get('app-terminal ion-textarea textarea').should('contain', data); + }); + + it('should clear the terminal', () => { + const data = 'test\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\n'; + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ + event: 'data', + data: data, + }); + } + }); + + cy.get('app-terminal ion-button').contains('Clear Terminal').click(); + cy.get('app-terminal ion-textarea textarea').should('contain', ''); + }); + + it('should auto scroll when data is emitted', () => { + const data = 'test\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\n'; + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ + event: 'data', + data: data, + }); + } + }); + + cy.get('app-terminal ion-textarea textarea').then((textarea) => { + const scrollTop = textarea[0].scrollTop; + const scrollHeight = textarea[0].scrollHeight; + const clientHeight = textarea[0].clientHeight; + + expect(scrollTop + clientHeight).to.equal(scrollHeight); + }); + }); + + it('should open and close the advanced modal', () => { + cy.get('app-terminal ion-button ion-icon').parent().click(); + cy.get('ion-modal').should('be.visible'); + cy.get('ion-modal ion-toolbar ion-button').click(); + cy.get('ion-modal').should('not.be.visible'); + }); + + it('should clear the terminal after changing encoding', () => { + const data = 'test\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\n'; + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ + event: 'data', + data: data, + }); + } + }); + + cy.get('app-terminal ion-button ion-icon').parent().click(); + cy.getAdvancedModalSelectElement( + 'terminal-advanced-modal', + 'Encoding' + ).selectOption('Hex'); + + cy.get('app-terminal ion-textarea textarea').should('contain', ''); + }); + + it('should clear the terminal after changing show timestamps', () => { + const data = 'test\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\n'; + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ + event: 'data', + data: data, + }); + } + }); + + cy.get('app-terminal ion-button ion-icon').parent().click(); + cy.getAdvancedModalCheckboxElement( + 'terminal-advanced-modal', + 'Show Timestamps' + ).click(); + + cy.get('app-terminal ion-textarea textarea').should('contain', ''); + }); + + it('should not auto scroll when auto scroll is disabled and data is emitted', () => { + let initialScrollTop = 0; + + const data = 'test\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\ntest\n'; + + cy.get('app-terminal ion-button ion-icon').parent().click(); + cy.getAdvancedModalCheckboxElement( + 'terminal-advanced-modal', + 'Auto Scroll' + ).click(); + cy.get('ion-modal ion-toolbar ion-button').click(); + + cy.get('textarea').then((textarea) => { + initialScrollTop = textarea[0].scrollTop; + }); + + cy.wrap(null).then(() => { + if (onEventTrigger) { + onEventTrigger({ + event: 'data', + data: data, + }); + } + }); + + cy.get('app-terminal ion-textarea textarea').then((textarea) => { + const currentScrollTop = textarea[0].scrollTop; + expect(currentScrollTop).to.equal(initialScrollTop); + }); + }); +}); diff --git a/apps/spie-ui-e2e/src/fixtures/example.json b/apps/spie-ui-e2e/src/fixtures/example.json deleted file mode 100644 index 02e4254..0000000 --- a/apps/spie-ui-e2e/src/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} diff --git a/apps/spie-ui-e2e/src/fixtures/mocks/electron-api.mock.ts b/apps/spie-ui-e2e/src/fixtures/mocks/electron-api.mock.ts new file mode 100644 index 0000000..79212a7 --- /dev/null +++ b/apps/spie-ui-e2e/src/fixtures/mocks/electron-api.mock.ts @@ -0,0 +1,21 @@ +import { type ElectronAPI } from '@spie/types'; + +export function mockElectronAPI(): ElectronAPI { + return { + platform: '', + quit: cy.stub(), + getVersion: cy.stub(), + downloadUpdate: cy.stub(), + installUpdate: cy.stub(), + onUpdateEvent: cy.stub(), + serialPort: { + list: cy.stub(), + open: cy.stub(), + close: cy.stub(), + isOpen: cy.stub().resolves(false), + write: cy.stub().resolves(true), + setReadEncoding: cy.stub(), + onEvent: cy.stub(), + }, + }; +} diff --git a/apps/spie-ui-e2e/src/support/app.po.ts b/apps/spie-ui-e2e/src/support/app.po.ts deleted file mode 100644 index adebf42..0000000 --- a/apps/spie-ui-e2e/src/support/app.po.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const getTittle = (): Cypress.Chainable> => - cy.get('ion-app ion-header ion-toolbar ion-title'); diff --git a/apps/spie-ui-e2e/src/support/commands.ts b/apps/spie-ui-e2e/src/support/commands.ts index 0fca37a..675a427 100644 --- a/apps/spie-ui-e2e/src/support/commands.ts +++ b/apps/spie-ui-e2e/src/support/commands.ts @@ -1,34 +1,46 @@ -/// +export {}; -// *********************************************** -// This example commands.ts shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** - -declare namespace Cypress { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface Chainable { - login(email: string, password: string): void; +declare global { + namespace Cypress { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Chainable { + getAdvancedModalSelectElement( + modal: string, + label: string + ): Cypress.Chainable; + getAdvancedModalCheckboxElement( + modal: string, + label: string + ): Cypress.Chainable; + selectOption(option: string | number): Cypress.Chainable; + } } } -// -- This is a parent command -- -Cypress.Commands.add('login', (email, password) => { - console.log('Custom command example: Login', email, password); -}); -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) +Cypress.Commands.add( + 'getAdvancedModalSelectElement', + (modal: string, label: string) => { + return cy + .get(`ion-modal#${modal} ion-content ion-list`) + .get(`[label="${label}"]`); + } +); + +Cypress.Commands.add( + 'getAdvancedModalCheckboxElement', + (modal: string, label: string) => { + return cy + .get(`ion-modal#${modal} ion-content ion-list ion-checkbox`) + .contains(label); + } +); + +Cypress.Commands.add( + 'selectOption', + { prevSubject: 'element' }, + (subject, option: string | number) => { + cy.wrap(subject).click(); + cy.get('ion-alert .alert-radio-button').contains(option).click(); + cy.get('ion-alert button.alert-button').contains('OK').click(); + } +); diff --git a/apps/spie-ui/src/app/app.component.html b/apps/spie-ui/src/app/app.component.html index 496d868..13b9677 100644 --- a/apps/spie-ui/src/app/app.component.html +++ b/apps/spie-ui/src/app/app.component.html @@ -1,400 +1,3 @@ - - - Serial Monitor - - - - - - - - Serial Port Configuration - - - - - - - - @for (serialPort of serialPorts(); track $index) { - - {{ serialPort.path }} - {{ serialPort.manufacturer }} - - } - - - - - - - - @for (baudRate of baudRates; track $index) { - {{ - baudRate - }} - } - - - - - - - - - @if (isOpen()) { - Disconnect - } @else { - Connect - } - - - - - - - - - - - @if (true) { - - - - - Advanced Connection Settings - - Close - - - - - - - - - 5 - 6 - 7 - 8 - - - - - - 1 - 1.5 - 2 - - - - - - None - Even - Odd - - - - - RTS/CTS - - - - XON - - - - XOFF - - - - XANY - - - - HUPCL - - - - - - } - - - - - - Terminal - - - - - - - - - - - - Clear Terminal - - - - - - - - - - - @if (true) { - - - - - Advanced Terminal Settings - - Close - - - - - - - - - ASCII - Hex - - - - - Auto Scroll - - - - Show Timestamps - - - - - - - - - - - } - - - - - - Send Data - - - - - - - - - - - Send - - - - - - - - - - - @if (true) { - - - - - Advanced Send Data Settings - - Close - - - - - - - - - ASCII - Hex - - - - - - None - CR (\r) - LF (\n) - CRLF (\r\n) - - - - - - - } - - + diff --git a/apps/spie-ui/src/app/app.component.scss b/apps/spie-ui/src/app/app.component.scss index 0557de8..e69de29 100644 --- a/apps/spie-ui/src/app/app.component.scss +++ b/apps/spie-ui/src/app/app.component.scss @@ -1,7 +0,0 @@ -.terminal-textarea, -.data-input { - --padding-start: 8px; - font-family: 'Courier New', monospace; - border: 1px solid #444; - border-radius: 4px; -} diff --git a/apps/spie-ui/src/app/app.component.ts b/apps/spie-ui/src/app/app.component.ts index 626fdc8..f64be71 100644 --- a/apps/spie-ui/src/app/app.component.ts +++ b/apps/spie-ui/src/app/app.component.ts @@ -1,640 +1,11 @@ -import { Component, inject, signal, viewChild } from '@angular/core'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { - type AlertButton, - AlertController, - IonApp, - IonButton, - IonButtons, - IonCard, - IonCardHeader, - IonCheckbox, - IonCol, - IonContent, - IonGrid, - IonHeader, - IonIcon, - IonInput, - IonItem, - IonList, - IonModal, - IonRange, - IonRow, - IonSelect, - IonSelectOption, - IonText, - IonTextarea, - IonTitle, - IonToolbar, - LoadingController, - ModalController, - ToastController, -} from '@ionic/angular/standalone'; -import { - type OpenOptions, - type PortInfo, -} from '@serialport/bindings-interface'; -import type { Delimiter, Encoding, SerialPortEvent } from '@spie/types'; -import { addIcons } from 'ionicons'; -import { - cloudUploadOutline, - documentOutline, - settingsOutline, - speedometerOutline, - statsChartOutline, - timeOutline, -} from 'ionicons/icons'; -import { Subject, filter, from, map, merge, scan, switchMap, tap } from 'rxjs'; - -import { ElectronService } from './electron.service'; -import { UpdateModalComponent } from './update-modal.component'; - -interface SelectChangeEventDetail { - value: T; -} -interface SelectCustomEvent extends CustomEvent { - detail: SelectChangeEventDetail; - target: HTMLIonSelectElement; -} - -interface CheckboxChangeEventDetail { - value: T; - checked: boolean; -} - -interface CheckboxCustomEvent extends CustomEvent { - detail: CheckboxChangeEventDetail; - target: HTMLIonCheckboxElement; -} - -type RangeValue = number | { lower: number; upper: number }; - -interface RangeChangeEventDetail { - value: RangeValue; -} - -interface RangeCustomEvent extends CustomEvent { - detail: RangeChangeEventDetail; - target: HTMLIonRangeElement; -} - -interface InputChangeEventDetail { - value?: string | undefined | null; -} -interface IonInputCustomEvent extends CustomEvent { - detail: InputChangeEventDetail; - target: HTMLIonInputElement; -} +import { Component } from '@angular/core'; +import { IonApp, IonRouterOutlet } from '@ionic/angular/standalone'; @Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrls: ['./app.component.scss'], standalone: true, - imports: [ - IonText, - IonApp, - IonButton, - IonButtons, - IonCard, - IonCardHeader, - IonCheckbox, - IonCol, - IonContent, - IonGrid, - IonHeader, - IonIcon, - IonInput, - IonItem, - IonList, - IonModal, - IonRange, - IonRow, - IonSelect, - IonSelectOption, - IonTextarea, - IonTitle, - IonToolbar, - ], + imports: [IonApp, IonRouterOutlet], }) -export class AppComponent { - private readonly alertController = inject(AlertController); - private readonly loadingController = inject(LoadingController); - private readonly modalController = inject(ModalController); - private readonly toastController = inject(ToastController); - private readonly electronService = inject(ElectronService); - - constructor() { - addIcons({ settingsOutline }); - addIcons({ documentOutline }); - addIcons({ cloudUploadOutline }); - addIcons({ speedometerOutline }); - addIcons({ statsChartOutline }); - addIcons({ timeOutline }); - } - - terminalTextArea = viewChild('terminalTextArea'); - sendInput = viewChild('sendInput'); - - baudRates = [ - 110, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 28800, 31250, 38400, - 57600, 115200, - ]; - - openOptions = signal({ - path: '', - baudRate: 9600, - dataBits: 8, - lock: true, - stopBits: 1, - parity: 'none', - rtscts: false, - xon: false, - xoff: false, - xany: false, - hupcl: true, - }); - scrollbackLength = signal(1); - delimiter = signal('lf'); - sendEncoding = signal('ascii'); - terminalEncoding = signal('ascii'); - serialPorts = signal([]); - isAutoScrollEnabled = signal(true); - showTimestampsEnabled = signal(false); - isSendInputValid = signal(false); - private clearTerminalSubject = new Subject(); - - isOpen = toSignal( - from(this.electronService.serialPort.isOpen()).pipe( - switchMap((isOpen) => - this.electronService.serialPort.onEvent().pipe( - filter( - (serialPortEvent) => - serialPortEvent.event === 'close' || - serialPortEvent.event === 'open' - ), - scan((currentIsOpen, serialPortEvent) => { - if (serialPortEvent.event === 'open') { - return true; - } - - if (serialPortEvent.event === 'close') { - return false; - } - - return currentIsOpen; - }, isOpen) - ) - ) - ), - { initialValue: false } - ); - - data = toSignal( - toObservable(this.isOpen).pipe( - switchMap(() => - merge( - // Emissions to this.isOpen will resubscribe these - this.electronService.serialPort.onEvent(), - this.clearTerminalSubject - ) - ), - filter((serialPortEvent) => serialPortEvent.event === 'data'), - map((serialPortEvent) => { - const data = serialPortEvent.data; - // If data it is clear terminal indication - if (data === '') { - return ''; - } - - if (this.showTimestampsEnabled()) { - return `${this.formatTimestamp(new Date())} ${data}`; - } - - return data; - }), - scan( - (acc, value) => { - // Reset on empty string - if (value === '') { - return { items: [] as string[], length: 0 }; - } - - acc.items.push(value); - acc.length += value.length; - const maxLength = this.scrollbackLength() * 10000; - - while (acc.length > maxLength) { - const removed = acc.items.shift(); - if (removed) { - acc.length -= removed.length; - } - } - - return acc; - }, - { items: [] as string[], length: 0 } - ), - map((buffer) => { - if (this.terminalEncoding() === 'hex') { - return buffer.items.join('\n'); - } - - return buffer.items.join(''); - }), - tap(async () => this.handleAutoScroll()) - ) - ); - - autoUpdaterEvent = toSignal( - this.electronService.onUpdateEvent().pipe( - tap((autoUpdaterEvent) => { - if (autoUpdaterEvent.event === 'checking-for-update') { - this.presentInfoToast('Checking for Updates'); - } - - if (autoUpdaterEvent.event === 'update-not-available') { - this.presentInfoToast('No Updates Available'); - } - - if (autoUpdaterEvent.event === 'update-available') { - this.presentAlert( - 'Update Available for Download', - `Version ${autoUpdaterEvent.updateInfo.version} is ready for download.`, - [ - { - text: 'Cancel', - role: 'cancel', - }, - { - text: 'Download', - role: 'confirm', - handler: async () => { - const modal = await this.modalController.create({ - component: UpdateModalComponent, - backdropDismiss: false, - id: 'update-modal', - componentProps: { - autoUpdaterEvent: this.autoUpdaterEvent, - }, - }); - await modal.present(); - }, - }, - ] - ); - } - - if (autoUpdaterEvent.event === 'update-downloaded') { - this.presentAlert( - 'Update Ready to Install', - `Version ${autoUpdaterEvent.updateDownloadedEvent.version} is ready to install.`, - [ - { - text: 'Cancel', - role: 'cancel', - }, - { - text: 'Install', - role: 'confirm', - handler: () => { - this.electronService.installUpdate(); - }, - }, - ] - ); - } - - if (autoUpdaterEvent.event === 'update-cancelled') { - this.presentErrorToast('Update Cancelled'); - } - }) - ) - ); - - async onClickSerialPort(event: MouseEvent): Promise { - const pointerEvent = event as PointerEvent; - const isClickFromMouse = - pointerEvent.pointerId > 0 && pointerEvent.pointerType === 'mouse'; - const isClickFromKeyboard = - pointerEvent.pointerId === -1 && - pointerEvent.clientX === 0 && - pointerEvent.clientY === 0; - const isCypressClick = - pointerEvent.pointerId === -1 && pointerEvent.pointerType === ''; - - if (isClickFromMouse || isClickFromKeyboard || isCypressClick) { - const loading = await this.loadingController.create(); - await loading.present(); - - try { - const serialPorts = await this.electronService.serialPort.list(); - this.serialPorts.set(serialPorts); - } catch (error) { - this.presentErrorToast(error); - } - - await loading.dismiss(); - } - } - - onChangeSerialPort(event: SelectCustomEvent): void { - const selectedOption = event.detail.value; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - path: selectedOption, - })); - } - - onChangeBaudRate(event: SelectCustomEvent): void { - const selectedOption = event.detail.value; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - baudRate: parseInt(selectedOption, 10), - })); - - this.applyConnectAdvanced(); - } - - async onClickConnect(): Promise { - const loading = await this.loadingController.create(); - await loading.present(); - - try { - await this.electronService.serialPort.open(this.openOptions()); - } catch (error) { - this.presentErrorToast(error); - } - - await loading.dismiss(); - } - - async onClickDisconnect(): Promise { - const loading = await this.loadingController.create(); - await loading.present(); - - try { - await this.electronService.serialPort.close(); - } catch (error) { - this.presentErrorToast(error); - } - - await loading.dismiss(); - } - - onChangeDataBits(event: SelectCustomEvent): void { - const selectedOption = event.detail.value; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - dataBits: parseInt(selectedOption, 10) as 5 | 6 | 7 | 8, - })); - - this.applyConnectAdvanced(); - } - - onChangeStopBits(event: SelectCustomEvent): void { - const selectedOption = event.detail.value; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - stopBits: parseFloat(selectedOption) as 1 | 1.5 | 2, - })); - - this.applyConnectAdvanced(); - } - - onChangeParity(event: SelectCustomEvent): void { - const selectedOption = event.detail.value; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - parity: selectedOption, - })); - - this.applyConnectAdvanced(); - } - - onChangeRtscts(event: CheckboxCustomEvent): void { - const selectedOption = event.detail.checked; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - rtscts: selectedOption, - })); - - this.applyConnectAdvanced(); - } - - onChangeXon(event: CheckboxCustomEvent): void { - const selectedOption = event.detail.checked; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - xon: selectedOption, - })); - - this.applyConnectAdvanced(); - } - - onChangeXoff(event: CheckboxCustomEvent): void { - const selectedOption = event.detail.checked; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - xoff: selectedOption, - })); - - this.applyConnectAdvanced(); - } - - onChangeXany(event: CheckboxCustomEvent): void { - const selectedOption = event.detail.checked; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - xany: selectedOption, - })); - - this.applyConnectAdvanced(); - } - - onChangeHupcl(event: CheckboxCustomEvent): void { - const selectedOption = event.detail.checked; - this.openOptions.update((currentOpenOptions) => ({ - ...currentOpenOptions, - hupcl: selectedOption, - })); - - this.applyConnectAdvanced(); - } - - onClickClearTerminal(): void { - this.clearTerminalSubject.next({ event: 'data', data: '' }); - } - - onChangeTerminalEncoding(event: SelectCustomEvent): void { - const selectedOption = event.detail.value; - this.terminalEncoding.set(selectedOption); - this.electronService.serialPort.setReadEncoding(selectedOption); - this.onClickClearTerminal(); - } - - onChangeAutoScroll(event: CheckboxCustomEvent): void { - const selectedOption = event.detail.checked; - this.isAutoScrollEnabled.set(selectedOption); - } - - onChangeShowTimestamps(event: CheckboxCustomEvent): void { - const selectedOption = event.detail.checked; - this.showTimestampsEnabled.set(selectedOption); - } - - onScrollbackLength(event: RangeCustomEvent): void { - const selectedOption = event.detail.value as number; - this.scrollbackLength.set(selectedOption); - } - - pinFormatter(value: number): string { - return `${value}0k`; - } - - async onClickSend(): Promise { - const rawData = this.sendInput()?.value as string; - if (rawData) { - const formatHexData = (data: string): string => data.replace(/\s+/g, ''); - const formatDelimitedData = (data: string): string => { - const delimiterMap: Record = { - none: '', - cr: '\r', - lf: '\n', - crlf: '\r\n', - }; - return data.concat(delimiterMap[this.delimiter()]); - }; - - const data = - this.sendEncoding() === 'hex' - ? formatHexData(rawData) - : formatDelimitedData(rawData); - - try { - const canDandleMoreData = await this.electronService.serialPort.write( - data, - this.sendEncoding() - ); - - if (!canDandleMoreData) { - this.presentWarningToast('Write buffer is full!'); - } - } catch (error) { - this.presentErrorToast(error); - } - } - } - - onChangeSendInput(event: IonInputCustomEvent): void { - const inputValue = event.detail.value; - if (!inputValue) { - this.isSendInputValid.set(false); - return; - } - - if (this.sendEncoding() !== 'hex') { - this.isSendInputValid.set(true); - return; - } - - const formattedHexValue = - inputValue - .replace(/[^a-fA-F0-9]/g, '') - .toUpperCase() - .match(/.{1,2}/g) - ?.join(' ') ?? ''; - event.target.value = formattedHexValue; - const isEvenLength = formattedHexValue.replace(/\s+/g, '').length % 2 === 0; - this.isSendInputValid.set(isEvenLength); - } - - onChangeSendEncoding(event: SelectCustomEvent): void { - const selectedOption = event.detail.value; - this.sendEncoding.set(selectedOption); - const input = this.sendInput(); - if (input) { - input.value = ''; - } - } - - onChangeDelimiter(event: SelectCustomEvent): void { - const selectedOption = event.detail.value; - this.delimiter.set(selectedOption); - this.applyConnectAdvanced(); - } - - private async applyConnectAdvanced(): Promise { - if (this.isOpen()) { - const loading = await this.loadingController.create(); - await loading.present(); - try { - await this.electronService.serialPort.close(); - await this.electronService.serialPort.open(this.openOptions()); - this.onClickClearTerminal(); - } catch (error) { - this.presentErrorToast(error); - } - await loading.dismiss(); - } - } - - private async presentToast( - header: string, - message?: string, - color?: string - ): Promise { - const toast = await this.toastController.create({ - header, - message, - duration: 3000, - position: 'bottom', - color, - }); - - await toast.present(); - } - - private async presentInfoToast(header: string): Promise { - await this.presentToast(header, undefined); - } - - private async presentWarningToast(message: string): Promise { - await this.presentToast('Warning', message, 'warning'); - } - - private async presentErrorToast(error: unknown): Promise { - await this.presentToast('Error', `${error}`, 'danger'); - } - - private async presentAlert( - header: string, - message?: string, - buttons?: (AlertButton | string)[] - ): Promise { - const alert = await this.alertController.create({ - header, - message, - buttons, - }); - alert.present(); - } - - private formatTimestamp(date: Date): string { - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); - const seconds = date.getSeconds().toString().padStart(2, '0'); - return `[${hours}:${minutes}:${seconds}]`; - } - - private async handleAutoScroll(): Promise { - const terminalTextArea = this.terminalTextArea(); - if (this.isAutoScrollEnabled() && terminalTextArea) { - const textarea = await terminalTextArea.getInputElement(); - textarea.scrollTo({ - top: textarea.scrollHeight, - behavior: 'instant', - }); - } - } -} +export class AppComponent {} diff --git a/apps/spie-ui/src/app/app.routes.ts b/apps/spie-ui/src/app/app.routes.ts new file mode 100644 index 0000000..5ffdc65 --- /dev/null +++ b/apps/spie-ui/src/app/app.routes.ts @@ -0,0 +1,20 @@ +import { type Routes } from '@angular/router'; + +import { HomeComponent } from './pages/home/home.component'; + +export const routes: Routes = [ + { + path: 'home', + component: HomeComponent, + }, + { + path: '', + redirectTo: 'home', + pathMatch: 'full', + }, + { + path: '**', + redirectTo: 'home', + pathMatch: 'full', + }, +]; diff --git a/apps/spie-ui/src/app/interfaces/app.interface.ts b/apps/spie-ui/src/app/interfaces/app.interface.ts new file mode 100644 index 0000000..084b49a --- /dev/null +++ b/apps/spie-ui/src/app/interfaces/app.interface.ts @@ -0,0 +1,14 @@ +import { type Delimiter, type Encoding } from '@spie/types'; + +export interface TerminalOptions { + encoding: Encoding; + isAutoScrollEnabled: boolean; + showTimestampsEnabled: boolean; + scrollbackLength: number; +} + +export interface SendOptions { + delimiter: Delimiter; + encoding: Encoding; + isSendInputValid: boolean; +} diff --git a/apps/spie-ui/src/app/interfaces/ionic.interface.ts b/apps/spie-ui/src/app/interfaces/ionic.interface.ts new file mode 100644 index 0000000..14d1246 --- /dev/null +++ b/apps/spie-ui/src/app/interfaces/ionic.interface.ts @@ -0,0 +1,37 @@ +export interface SelectChangeEventDetail { + value: T; +} + +export interface SelectCustomEvent extends CustomEvent { + detail: SelectChangeEventDetail; + target: HTMLIonSelectElement; +} + +export interface CheckboxChangeEventDetail { + value: T; + checked: boolean; +} + +export interface CheckboxCustomEvent extends CustomEvent { + detail: CheckboxChangeEventDetail; + target: HTMLIonCheckboxElement; +} + +type RangeValue = number | { lower: number; upper: number }; + +export interface RangeChangeEventDetail { + value: RangeValue; +} + +export interface RangeCustomEvent extends CustomEvent { + detail: RangeChangeEventDetail; + target: HTMLIonRangeElement; +} + +export interface InputChangeEventDetail { + value?: string | undefined | null; +} +export interface IonInputCustomEvent extends CustomEvent { + detail: InputChangeEventDetail; + target: HTMLIonInputElement; +} diff --git a/apps/spie-ui/src/app/pages/home/connection/connection-advanced-modal/connection-advanced-modal.component.html b/apps/spie-ui/src/app/pages/home/connection/connection-advanced-modal/connection-advanced-modal.component.html new file mode 100644 index 0000000..2328b8a --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/connection/connection-advanced-modal/connection-advanced-modal.component.html @@ -0,0 +1,106 @@ + + + + + Advanced Connection Settings + + Close + + + + + + + + + 5 + 6 + 7 + 8 + + + + + + 1 + 1.5 + 2 + + + + + + None + Even + Odd + + + + + RTS/CTS + + + + XON + + + + XOFF + + + + XANY + + + + HUPCL + + + + + diff --git a/apps/spie-ui/src/app/pages/home/connection/connection-advanced-modal/connection-advanced-modal.component.scss b/apps/spie-ui/src/app/pages/home/connection/connection-advanced-modal/connection-advanced-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spie-ui/src/app/pages/home/connection/connection-advanced-modal/connection-advanced-modal.component.ts b/apps/spie-ui/src/app/pages/home/connection/connection-advanced-modal/connection-advanced-modal.component.ts new file mode 100644 index 0000000..89a32c0 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/connection/connection-advanced-modal/connection-advanced-modal.component.ts @@ -0,0 +1,131 @@ +import { Component, input, model, viewChild } from '@angular/core'; +import { + IonButton, + IonButtons, + IonCheckbox, + IonContent, + IonHeader, + IonItem, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, +} from '@ionic/angular/standalone'; +import { type OpenOptions } from '@serialport/bindings-interface'; +import { type Subject } from 'rxjs'; + +import { + type CheckboxCustomEvent, + type SelectCustomEvent, +} from '../../../../interfaces/ionic.interface'; + +@Component({ + selector: 'app-connection-advanced-modal', + templateUrl: 'connection-advanced-modal.component.html', + styleUrls: ['./connection-advanced-modal.component.scss'], + standalone: true, + imports: [ + IonButton, + IonButtons, + IonCheckbox, + IonContent, + IonHeader, + IonItem, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, + ], +}) +export class ConnectionAdvancedComponent { + reconnectSubject = input.required>(); + openOptions = model.required(); + + connectionAdvancedModal = viewChild.required( + 'connectionAdvancedModal' + ); + + onChangeDataBits(event: SelectCustomEvent): void { + const selectedOption = event.detail.value; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + dataBits: parseInt(selectedOption, 10) as 5 | 6 | 7 | 8, + })); + + this.reconnectSubject().next(); + } + + onChangeStopBits(event: SelectCustomEvent): void { + const selectedOption = event.detail.value; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + stopBits: parseFloat(selectedOption) as 1 | 1.5 | 2, + })); + + this.reconnectSubject().next(); + } + + onChangeParity(event: SelectCustomEvent): void { + const selectedOption = event.detail.value; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + parity: selectedOption, + })); + + this.reconnectSubject().next(); + } + + onChangeRtscts(event: CheckboxCustomEvent): void { + const selectedOption = event.detail.checked; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + rtscts: selectedOption, + })); + + this.reconnectSubject().next(); + } + + onChangeXon(event: CheckboxCustomEvent): void { + const selectedOption = event.detail.checked; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + xon: selectedOption, + })); + + this.reconnectSubject().next(); + } + + onChangeXoff(event: CheckboxCustomEvent): void { + const selectedOption = event.detail.checked; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + xoff: selectedOption, + })); + + this.reconnectSubject().next(); + } + + onChangeXany(event: CheckboxCustomEvent): void { + const selectedOption = event.detail.checked; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + xany: selectedOption, + })); + + this.reconnectSubject().next(); + } + + onChangeHupcl(event: CheckboxCustomEvent): void { + const selectedOption = event.detail.checked; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + hupcl: selectedOption, + })); + + this.reconnectSubject().next(); + } +} diff --git a/apps/spie-ui/src/app/pages/home/connection/connection.component.html b/apps/spie-ui/src/app/pages/home/connection/connection.component.html new file mode 100644 index 0000000..5595e27 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/connection/connection.component.html @@ -0,0 +1,76 @@ + + + Connection + + + + + + + + @for (serialPort of serialPorts(); track $index) { + + {{ serialPort.path }} - {{ serialPort.manufacturer }} + + } + + + + + + + + @for (baudRate of baudRates; track $index) { + {{ + baudRate + }} + } + + + + + + + + @if (isOpen()) { + Disconnect + } @else { + Connect + } + + + + + + + + + + + diff --git a/apps/spie-ui/src/app/pages/home/connection/connection.component.scss b/apps/spie-ui/src/app/pages/home/connection/connection.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spie-ui/src/app/pages/home/connection/connection.component.ts b/apps/spie-ui/src/app/pages/home/connection/connection.component.ts new file mode 100644 index 0000000..5c914ed --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/connection/connection.component.ts @@ -0,0 +1,146 @@ +import { + Component, + inject, + input, + model, + signal, + viewChild, +} from '@angular/core'; +import { + IonButton, + IonCard, + IonCardHeader, + IonCol, + IonGrid, + IonIcon, + IonItem, + IonRow, + IonSelect, + IonSelectOption, + IonText, + LoadingController, +} from '@ionic/angular/standalone'; +import { + type OpenOptions, + type PortInfo, +} from '@serialport/bindings-interface'; +import { type Subject } from 'rxjs'; + +import { ConnectionAdvancedComponent } from './connection-advanced-modal/connection-advanced-modal.component'; +import { type SelectCustomEvent } from '../../../interfaces/ionic.interface'; +import { ElectronService } from '../../../services/electron.service'; +import { ToasterService } from '../../../services/toaster.service'; + +@Component({ + selector: 'app-connection', + templateUrl: './connection.component.html', + styleUrls: ['./connection.component.scss'], + standalone: true, + imports: [ + IonButton, + IonCard, + IonCardHeader, + IonCol, + IonGrid, + IonIcon, + IonItem, + IonRow, + IonSelect, + IonSelectOption, + IonText, + ConnectionAdvancedComponent, + ], +}) +export class ConnectionComponent { + private readonly loadingController = inject(LoadingController); + private readonly toasterService = inject(ToasterService); + private readonly electronService = inject(ElectronService); + + reconnectSubject = input.required>(); + isOpen = input.required(); + openOptions = model.required(); + + private connectionAdvancedComponent = viewChild.required( + ConnectionAdvancedComponent + ); + + baudRates = [ + 110, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 28800, 31250, 38400, + 57600, 115200, + ]; + serialPorts = signal([]); + + async onClickSerialPort(event: MouseEvent): Promise { + const pointerEvent = event as PointerEvent; + const isClickFromMouse = + pointerEvent.pointerId > 0 && pointerEvent.pointerType === 'mouse'; + const isClickFromKeyboard = + pointerEvent.pointerId === -1 && + pointerEvent.clientX === 0 && + pointerEvent.clientY === 0; + const isCypressClick = + pointerEvent.pointerId === -1 && pointerEvent.pointerType === ''; + + if (isClickFromMouse || isClickFromKeyboard || isCypressClick) { + const loading = await this.loadingController.create(); + await loading.present(); + + try { + const serialPorts = await this.electronService.serialPort.list(); + this.serialPorts.set(serialPorts); + } catch (error) { + await this.toasterService.presentErrorToast(error); + } + + await loading.dismiss(); + } + } + + onChangeSerialPort(event: SelectCustomEvent): void { + const selectedOption = event.detail.value; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + path: selectedOption, + })); + } + + onChangeBaudRate(event: SelectCustomEvent): void { + const selectedOption = event.detail.value; + this.openOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + baudRate: parseInt(selectedOption, 10), + })); + + this.reconnectSubject().next(); + } + + async onClickDisconnect(): Promise { + const loading = await this.loadingController.create(); + await loading.present(); + + try { + await this.electronService.serialPort.close(); + } catch (error) { + await this.toasterService.presentErrorToast(error); + } + + await loading.dismiss(); + } + + async onClickConnect(): Promise { + const loading = await this.loadingController.create(); + await loading.present(); + + try { + await this.electronService.serialPort.open(this.openOptions()); + } catch (error) { + await this.toasterService.presentErrorToast(error); + } + + await loading.dismiss(); + } + + async onClickConnectionAdvancedModal() { + this.connectionAdvancedComponent().connectionAdvancedModal().present(); + } +} diff --git a/apps/spie-ui/src/app/pages/home/home.component.html b/apps/spie-ui/src/app/pages/home/home.component.html new file mode 100644 index 0000000..6493f6b --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/home.component.html @@ -0,0 +1,28 @@ + + + Serial Monitor + + + + + + + + + + + + + + + diff --git a/apps/spie-ui/src/app/pages/home/home.component.scss b/apps/spie-ui/src/app/pages/home/home.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spie-ui/src/app/pages/home/home.component.ts b/apps/spie-ui/src/app/pages/home/home.component.ts new file mode 100644 index 0000000..6c6ccf2 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/home.component.ts @@ -0,0 +1,341 @@ +import { Component, inject, signal, viewChild } from '@angular/core'; +import { + takeUntilDestroyed, + toObservable, + toSignal, +} from '@angular/core/rxjs-interop'; +import { + type AlertButton, + AlertController, + IonContent, + IonHeader, + IonTitle, + IonToolbar, + LoadingController, +} from '@ionic/angular/standalone'; +import { type OpenOptions } from '@serialport/bindings-interface'; +import { type SerialPortEvent } from '@spie/types'; +import { addIcons } from 'ionicons'; +import { + cloudUploadOutline, + documentOutline, + settingsOutline, + speedometerOutline, + statsChartOutline, + timeOutline, +} from 'ionicons/icons'; +import { + Subject, + filter, + from, + map, + merge, + of, + scan, + switchMap, + tap, +} from 'rxjs'; + +import { ConnectionComponent } from './connection/connection.component'; +import { SendComponent } from './send/send.component'; +import { TerminalComponent } from './terminal/terminal.component'; +import { UpdateModalComponent } from './update-modal/update-modal.component'; +import { + type SendOptions, + type TerminalOptions, +} from '../../interfaces/app.interface'; +import { ElectronService } from '../../services/electron.service'; +import { ToasterService } from '../../services/toaster.service'; + +@Component({ + selector: 'app-home', + templateUrl: 'home.component.html', + styleUrls: ['./home.component.scss'], + standalone: true, + imports: [ + IonContent, + IonHeader, + IonTitle, + IonToolbar, + SendComponent, + ConnectionComponent, + TerminalComponent, + UpdateModalComponent, + ], +}) +export class HomeComponent { + private readonly alertController = inject(AlertController); + private readonly loadingController = inject(LoadingController); + private readonly toasterService = inject(ToasterService); + private readonly electronService = inject(ElectronService); + + constructor() { + addIcons({ settingsOutline }); + addIcons({ documentOutline }); + addIcons({ cloudUploadOutline }); + addIcons({ speedometerOutline }); + addIcons({ statsChartOutline }); + addIcons({ timeOutline }); + + this.reconnectSubject + .pipe( + takeUntilDestroyed(), + tap(async () => { + if (this.isOpen()) { + const loading = await this.loadingController.create(); + await loading.present(); + try { + await this.electronService.serialPort.close(); + await this.electronService.serialPort.open(this.openOptions()); + this.clearTerminalSubject.next({ event: 'data', data: '' }); + } catch (error) { + await this.toasterService.presentErrorToast(error); + } + await loading.dismiss(); + } + }) + ) + .subscribe(); + } + + private terminalComponent = + viewChild.required(TerminalComponent); + private updateModalComponent = viewChild.required(UpdateModalComponent); + + openOptions = signal({ + path: '', + baudRate: 9600, + dataBits: 8, + lock: true, + stopBits: 1, + parity: 'none', + rtscts: false, + xon: false, + xoff: false, + xany: false, + hupcl: true, + }); + + terminalOptions = signal({ + encoding: 'ascii', + isAutoScrollEnabled: true, + showTimestampsEnabled: false, + scrollbackLength: 1, + }); + + sendOptions = signal({ + delimiter: 'lf', + encoding: 'ascii', + isSendInputValid: false, + }); + + reconnectSubject = new Subject(); + clearTerminalSubject = new Subject(); + clearInputSubject = new Subject(); + + isOpen = toSignal( + from(this.electronService.serialPort.isOpen()).pipe( + switchMap((isOpen) => + this.electronService.serialPort.onEvent().pipe( + filter( + (serialPortEvent) => + serialPortEvent.event === 'close' || + serialPortEvent.event === 'open' + ), + scan((currentIsOpen, serialPortEvent) => { + if (serialPortEvent.event === 'open') { + return true; + } + + if (serialPortEvent.event === 'close') { + return false; + } + + return currentIsOpen; + }, isOpen) + ) + ) + ), + { initialValue: false } + ); + + data = toSignal( + toObservable(this.isOpen).pipe( + switchMap(() => + merge( + // Emissions to this.isOpen will resubscribe these + this.electronService.serialPort.onEvent(), + this.clearTerminalSubject + ) + ), + filter((serialPortEvent) => serialPortEvent.event === 'data'), + map((serialPortEvent) => { + const data = serialPortEvent.data; + // If data it a "clear terminal" signal + if (data === '') { + return ''; + } + + if (this.terminalOptions().showTimestampsEnabled) { + return `${this.formatTimestamp(new Date())} ${data}`; + } + + return data; + }), + scan( + (acc, value) => { + // Reset on empty string + if (value === '') { + return { items: [] as string[], length: 0 }; + } + + acc.items.push(value); + acc.length += value.length; + const maxLength = this.terminalOptions().scrollbackLength * 10000; + + while (acc.length > maxLength) { + const removed = acc.items.shift(); + if (removed) { + acc.length -= removed.length; + } + } + + return acc; + }, + { items: [] as string[], length: 0 } + ), + map((buffer) => { + if (this.terminalOptions().encoding === 'hex') { + return buffer.items.join('\n'); + } + + return buffer.items.join(''); + }), + tap(async () => await this.handleAutoScroll()) + ), + { initialValue: '' } + ); + + progressInfo = toSignal( + this.electronService.onUpdateEvent().pipe( + tap(async (autoUpdaterEvent) => { + if (autoUpdaterEvent.event === 'checking-for-update') { + await this.toasterService.presentInfoToast('Checking for Updates'); + } + + if (autoUpdaterEvent.event === 'update-not-available') { + await this.toasterService.presentInfoToast('No Updates Available'); + } + + if (autoUpdaterEvent.event === 'update-available') { + await this.presentAlert( + 'Update Available for Download', + `Version ${autoUpdaterEvent.updateInfo.version} is ready for download.`, + [ + { + text: 'Cancel', + role: 'cancel', + }, + { + text: 'Download', + role: 'confirm', + handler: async () => { + this.electronService.downloadUpdate(); + await this.updateModalComponent().updateModal().present(); + }, + }, + ] + ); + } + + if (autoUpdaterEvent.event === 'update-downloaded') { + await this.presentAlert( + 'Update Ready to Install', + `Version ${autoUpdaterEvent.updateDownloadedEvent.version} is ready to install.`, + [ + { + text: 'Cancel', + role: 'cancel', + }, + { + text: 'Install', + role: 'confirm', + handler: () => { + this.electronService.installUpdate(); + }, + }, + ] + ); + } + + if (autoUpdaterEvent.event === 'update-cancelled') { + await this.toasterService.presentErrorToast('Update Cancelled'); + } + + if ( + autoUpdaterEvent.event === 'update-downloaded' || + autoUpdaterEvent.event === 'update-cancelled' || + autoUpdaterEvent.event === 'error' + ) { + await this.updateModalComponent().updateModal().dismiss(); + } + }), + switchMap((autoUpdaterEvent) => { + if (autoUpdaterEvent.event === 'download-progress') { + return of(autoUpdaterEvent.progressInfo); + } + + return of({ + total: 0, + delta: 0, + transferred: 0, + percent: 0, + bytesPerSecond: 0, + }); + }) + ), + { + initialValue: { + total: 0, + delta: 0, + transferred: 0, + percent: 0, + bytesPerSecond: 0, + }, + } + ); + + private async presentAlert( + header: string, + message?: string, + buttons?: (AlertButton | string)[] + ): Promise { + const alert = await this.alertController.create({ + header, + message, + buttons, + }); + await alert.present(); + } + + private formatTimestamp(date: Date): string { + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const seconds = date.getSeconds().toString().padStart(2, '0'); + return `[${hours}:${minutes}:${seconds}]`; + } + + async handleAutoScroll(): Promise { + const isAutoScrollEnabled = this.terminalOptions().isAutoScrollEnabled; + + if (isAutoScrollEnabled) { + const terminalTextArea = this.terminalComponent().terminalTextArea(); + const textarea = await terminalTextArea.getInputElement(); + + textarea.scrollTo({ + top: textarea.scrollHeight, + behavior: 'instant', + }); + } + } +} diff --git a/apps/spie-ui/src/app/pages/home/send/send-advanced-modal/send-advanced-modal.component.html b/apps/spie-ui/src/app/pages/home/send/send-advanced-modal/send-advanced-modal.component.html new file mode 100644 index 0000000..7af8c1b --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/send/send-advanced-modal/send-advanced-modal.component.html @@ -0,0 +1,44 @@ + + + + + Advanced Send Settings + + Close + + + + + + + + + ASCII + Hex + + + + + + None + CR (\r) + LF (\n) + CRLF (\r\n) + + + + + + diff --git a/apps/spie-ui/src/app/pages/home/send/send-advanced-modal/send-advanced-modal.component.scss b/apps/spie-ui/src/app/pages/home/send/send-advanced-modal/send-advanced-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spie-ui/src/app/pages/home/send/send-advanced-modal/send-advanced-modal.component.ts b/apps/spie-ui/src/app/pages/home/send/send-advanced-modal/send-advanced-modal.component.ts new file mode 100644 index 0000000..05355d7 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/send/send-advanced-modal/send-advanced-modal.component.ts @@ -0,0 +1,59 @@ +import { Component, model, viewChild } from '@angular/core'; +import { + IonButton, + IonButtons, + IonContent, + IonHeader, + IonItem, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, +} from '@ionic/angular/standalone'; +import { type Delimiter, type Encoding } from '@spie/types'; + +import { type SendOptions } from '../../../../interfaces/app.interface'; +import { type SelectCustomEvent } from '../../../../interfaces/ionic.interface'; + +@Component({ + selector: 'app-send-advanced-modal', + templateUrl: 'send-advanced-modal.component.html', + styleUrls: ['./send-advanced-modal.component.scss'], + standalone: true, + imports: [ + IonButton, + IonButtons, + IonContent, + IonHeader, + IonItem, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, + ], +}) +export class SendAdvancedComponent { + sendOptions = model.required(); + + sendAdvancedModal = viewChild.required('sendAdvancedModal'); + + onChangeSendEncoding(event: SelectCustomEvent): void { + const selectedOption = event.detail.value; + this.sendOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + encoding: selectedOption, + })); + } + + onChangeDelimiter(event: SelectCustomEvent): void { + const selectedOption = event.detail.value; + this.sendOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + delimiter: selectedOption, + })); + } +} diff --git a/apps/spie-ui/src/app/pages/home/send/send.component.html b/apps/spie-ui/src/app/pages/home/send/send.component.html new file mode 100644 index 0000000..7fdeb50 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/send/send.component.html @@ -0,0 +1,41 @@ + + + Send + + + + + + + + + + Send + + + + + + + + + + + diff --git a/apps/spie-ui/src/app/pages/home/send/send.component.scss b/apps/spie-ui/src/app/pages/home/send/send.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spie-ui/src/app/pages/home/send/send.component.ts b/apps/spie-ui/src/app/pages/home/send/send.component.ts new file mode 100644 index 0000000..21c51cb --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/send/send.component.ts @@ -0,0 +1,140 @@ +import { Component, inject, input, model, viewChild } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { + IonButton, + IonCard, + IonCardHeader, + IonCol, + IonGrid, + IonIcon, + IonInput, + IonItem, + IonRow, + IonText, +} from '@ionic/angular/standalone'; +import { type Delimiter } from '@spie/types'; +import { scan } from 'rxjs'; + +import { SendAdvancedComponent } from './send-advanced-modal/send-advanced-modal.component'; +import { type SendOptions } from '../../../interfaces/app.interface'; +import { type IonInputCustomEvent } from '../../../interfaces/ionic.interface'; +import { ElectronService } from '../../../services/electron.service'; +import { ToasterService } from '../../../services/toaster.service'; + +@Component({ + selector: 'app-send', + templateUrl: 'send.component.html', + styleUrls: ['./send.component.scss'], + standalone: true, + imports: [ + IonButton, + IonCard, + IonCardHeader, + IonCol, + IonGrid, + IonIcon, + IonInput, + IonItem, + IonRow, + IonText, + SendAdvancedComponent, + ], +}) +export class SendComponent { + private readonly toasterService = inject(ToasterService); + private readonly electronService = inject(ElectronService); + + constructor() { + toObservable(this.sendOptions) + .pipe( + takeUntilDestroyed(), + scan((currentSendOptions, sendOptions) => { + // Reset input field if encoding changes + if (currentSendOptions.encoding !== sendOptions.encoding) { + this.sendInput().value = ''; + } + + return sendOptions; + }) + ) + .subscribe(); + } + + isOpen = input.required(); + sendOptions = model.required(); + + private sendInput = viewChild.required('sendInput'); + private sendAdvancedComponent = viewChild.required(SendAdvancedComponent); + + async onClickSend(): Promise { + const rawData = this.sendInput().value as string; + if (rawData) { + const formatHexData = (data: string): string => data.replace(/\s+/g, ''); + const formatDelimitedData = (data: string): string => { + const delimiterMap: Record = { + none: '', + cr: '\r', + lf: '\n', + crlf: '\r\n', + }; + return data.concat(delimiterMap[this.sendOptions().delimiter]); + }; + + const data = + this.sendOptions().encoding === 'hex' + ? formatHexData(rawData) + : formatDelimitedData(rawData); + + try { + const canHandleMoreData = await this.electronService.serialPort.write( + data, + this.sendOptions().encoding + ); + + if (!canHandleMoreData) { + await this.toasterService.presentWarningToast( + 'Write buffer is full!' + ); + } + } catch (error) { + await this.toasterService.presentErrorToast(error); + } + } + } + + onChangeSendInput(event: IonInputCustomEvent): void { + const inputValue = event.detail.value; + if (!inputValue) { + this.sendOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + isSendInputValid: false, + })); + return; + } + + if (this.sendOptions().encoding !== 'hex') { + this.sendOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + isSendInputValid: true, + })); + return; + } + + const formattedHexValue = + inputValue + .replace(/[^a-fA-F0-9]/g, '') + .toUpperCase() + .match(/.{1,2}/g) + ?.join(' ') ?? ''; + event.target.value = formattedHexValue; + const isEvenLength = formattedHexValue.replace(/\s+/g, '').length % 2 === 0; + this.sendOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + isSendInputValid: isEvenLength, + })); + } + + async onClickSendAdvancedModal() { + this.sendAdvancedComponent().sendAdvancedModal().present(); + } +} diff --git a/apps/spie-ui/src/app/pages/home/terminal/terminal-advanced-modal/terminal-advanced-modal.component.html b/apps/spie-ui/src/app/pages/home/terminal/terminal-advanced-modal/terminal-advanced-modal.component.html new file mode 100644 index 0000000..006abf2 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/terminal/terminal-advanced-modal/terminal-advanced-modal.component.html @@ -0,0 +1,64 @@ + + + + + Advanced Terminal Settings + + Close + + + + + + + + + ASCII + Hex + + + + + Show Timestamps + + + + Auto Scroll + + + + + + + + + + diff --git a/apps/spie-ui/src/app/pages/home/terminal/terminal-advanced-modal/terminal-advanced-modal.component.scss b/apps/spie-ui/src/app/pages/home/terminal/terminal-advanced-modal/terminal-advanced-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/spie-ui/src/app/pages/home/terminal/terminal-advanced-modal/terminal-advanced-modal.component.ts b/apps/spie-ui/src/app/pages/home/terminal/terminal-advanced-modal/terminal-advanced-modal.component.ts new file mode 100644 index 0000000..e693909 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/terminal/terminal-advanced-modal/terminal-advanced-modal.component.ts @@ -0,0 +1,100 @@ +import { Component, inject, input, model, viewChild } from '@angular/core'; +import { + IonButton, + IonButtons, + IonCheckbox, + IonContent, + IonHeader, + IonItem, + IonList, + IonModal, + IonRange, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, +} from '@ionic/angular/standalone'; +import { type Encoding, type SerialPortEvent } from '@spie/types'; +import { type Subject } from 'rxjs'; + +import { type TerminalOptions } from '../../../../interfaces/app.interface'; +import { + type CheckboxCustomEvent, + type RangeCustomEvent, + type SelectCustomEvent, +} from '../../../../interfaces/ionic.interface'; +import { ElectronService } from '../../../../services/electron.service'; + +@Component({ + selector: 'app-terminal-advanced-modal', + templateUrl: 'terminal-advanced-modal.component.html', + styleUrls: ['./terminal-advanced-modal.component.scss'], + standalone: true, + imports: [ + IonButton, + IonButtons, + IonCheckbox, + IonContent, + IonHeader, + IonItem, + IonList, + IonModal, + IonRange, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, + ], +}) +export class TerminalAdvancedComponent { + private readonly electronService = inject(ElectronService); + + clearTerminalSubject = input.required>(); + terminalOptions = model.required(); + + terminalAdvancedModal = viewChild.required('terminalAdvancedModal'); + + onChangeTerminalEncoding(event: SelectCustomEvent): void { + const selectedOption = event.detail.value; + this.terminalOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + encoding: selectedOption, + })); + + this.electronService.serialPort.setReadEncoding(selectedOption); + this.clearTerminalSubject().next({ event: 'data', data: '' }); + } + + onChangeShowTimestamps(event: CheckboxCustomEvent): void { + const selectedOption = event.detail.checked; + this.terminalOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + showTimestampsEnabled: selectedOption, + })); + + this.clearTerminalSubject().next({ event: 'data', data: '' }); + } + + onChangeAutoScroll(event: CheckboxCustomEvent): void { + const selectedOption = event.detail.checked; + this.terminalOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + isAutoScrollEnabled: selectedOption, + })); + + // this.clearTerminalSubject().next({ event: 'data', data: '' }); + } + onScrollbackLength(event: RangeCustomEvent): void { + const selectedOption = event.detail.value as number; + this.terminalOptions.update((currentOpenOptions) => ({ + ...currentOpenOptions, + scrollbackLength: selectedOption, + })); + + // this.clearTerminalSubject().next({ event: 'data', data: '' }); + } + + pinFormatter(value: number): string { + return `${value}0k`; + } +} diff --git a/apps/spie-ui/src/app/pages/home/terminal/terminal.component.html b/apps/spie-ui/src/app/pages/home/terminal/terminal.component.html new file mode 100644 index 0000000..6abdc64 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/terminal/terminal.component.html @@ -0,0 +1,43 @@ + + + Terminal + + + + + + + + + + Clear Terminal + + + + + + + + + + + diff --git a/apps/spie-ui/src/app/pages/home/terminal/terminal.component.scss b/apps/spie-ui/src/app/pages/home/terminal/terminal.component.scss new file mode 100644 index 0000000..0557de8 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/terminal/terminal.component.scss @@ -0,0 +1,7 @@ +.terminal-textarea, +.data-input { + --padding-start: 8px; + font-family: 'Courier New', monospace; + border: 1px solid #444; + border-radius: 4px; +} diff --git a/apps/spie-ui/src/app/pages/home/terminal/terminal.component.ts b/apps/spie-ui/src/app/pages/home/terminal/terminal.component.ts new file mode 100644 index 0000000..7ed3aa7 --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/terminal/terminal.component.ts @@ -0,0 +1,56 @@ +import { Component, input, model, viewChild } from '@angular/core'; +import { + IonButton, + IonCard, + IonCardHeader, + IonCol, + IonGrid, + IonIcon, + IonItem, + IonRow, + IonText, + IonTextarea, +} from '@ionic/angular/standalone'; +import { type SerialPortEvent } from '@spie/types'; +import { type Subject } from 'rxjs'; + +import { TerminalAdvancedComponent } from './terminal-advanced-modal/terminal-advanced-modal.component'; +import { type TerminalOptions } from '../../../interfaces/app.interface'; + +@Component({ + selector: 'app-terminal', + templateUrl: 'terminal.component.html', + styleUrls: ['./terminal.component.scss'], + standalone: true, + imports: [ + IonButton, + IonCard, + IonCardHeader, + IonCol, + IonGrid, + IonIcon, + IonItem, + IonRow, + IonText, + IonTextarea, + TerminalAdvancedComponent, + ], +}) +export class TerminalComponent { + clearTerminalSubject = input.required>(); + data = input.required(); + terminalOptions = model.required(); + + terminalTextArea = viewChild.required('terminalTextArea'); + private terminalAdvancedComponent = viewChild.required( + TerminalAdvancedComponent + ); + + onClickClearTerminal(): void { + this.clearTerminalSubject().next({ event: 'data', data: '' }); + } + + async onClickTerminalAdvancedModal() { + this.terminalAdvancedComponent().terminalAdvancedModal().present(); + } +} diff --git a/apps/spie-ui/src/app/pages/home/update-modal/update-modal.component.html b/apps/spie-ui/src/app/pages/home/update-modal/update-modal.component.html new file mode 100644 index 0000000..2ddd42c --- /dev/null +++ b/apps/spie-ui/src/app/pages/home/update-modal/update-modal.component.html @@ -0,0 +1,57 @@ + + + + + Transfer Progress + + @if (progressInfo().percent===0) { + + } + + + + + + + + Total Size + {{ + formatBytes(progressInfo().total) + }} + + + + + Transferred + {{ + formatBytes(progressInfo().transferred) + }} + + + + + Speed + {{ formatBytes(progressInfo().bytesPerSecond) }}/s + + + + + Progress + {{ progressInfo().percent | number : '1.2-2' }}% + + + + + Time Remaining + {{ getEstimatedTime() }} + + + + + diff --git a/apps/spie-ui/src/app/update-modal.component.scss b/apps/spie-ui/src/app/pages/home/update-modal/update-modal.component.scss similarity index 100% rename from apps/spie-ui/src/app/update-modal.component.scss rename to apps/spie-ui/src/app/pages/home/update-modal/update-modal.component.scss diff --git a/apps/spie-ui/src/app/update-modal.component.ts b/apps/spie-ui/src/app/pages/home/update-modal/update-modal.component.ts similarity index 57% rename from apps/spie-ui/src/app/update-modal.component.ts rename to apps/spie-ui/src/app/pages/home/update-modal/update-modal.component.ts index c72c008..2523c2f 100644 --- a/apps/spie-ui/src/app/update-modal.component.ts +++ b/apps/spie-ui/src/app/pages/home/update-modal/update-modal.component.ts @@ -1,5 +1,5 @@ import { DecimalPipe } from '@angular/common'; -import { Component, computed, effect, inject, input } from '@angular/core'; +import { Component, input, viewChild } from '@angular/core'; import { IonContent, IonHeader, @@ -7,31 +7,30 @@ import { IonItem, IonLabel, IonList, + IonModal, IonNote, IonProgressBar, IonSpinner, IonTitle, IonToolbar, - ModalController, } from '@ionic/angular/standalone'; -import { type AutoUpdaterEvent } from '@spie/types'; - -import { ElectronService } from './electron.service'; +import { type ProgressInfo } from 'electron-updater'; @Component({ - selector: 'app-dfu-progress', + selector: 'app-update-modal', templateUrl: './update-modal.component.html', styleUrls: ['./update-modal.component.scss'], standalone: true, imports: [ DecimalPipe, IonContent, + IonHeader, IonIcon, IonItem, IonLabel, IonList, + IonModal, IonNote, - IonHeader, IonProgressBar, IonSpinner, IonTitle, @@ -39,39 +38,9 @@ import { ElectronService } from './electron.service'; ], }) export class UpdateModalComponent { - private readonly modalController: ModalController = inject(ModalController); - private readonly electronService = inject(ElectronService); - - autoUpdaterEvent = input.required(); - progressInfo = computed(() => { - const autoUpdaterEvent = this.autoUpdaterEvent(); - if (autoUpdaterEvent?.event === 'download-progress') { - return autoUpdaterEvent.progressInfo; - } - - return { - total: 0, - delta: 0, - transferred: 0, - percent: 0, - bytesPerSecond: 0, - }; - }); + updateModal = viewChild.required('updateModal'); - constructor() { - this.electronService.downloadUpdate(); - - effect(async () => { - const autoUpdaterEvent = this.autoUpdaterEvent(); - if ( - autoUpdaterEvent.event === 'update-downloaded' || - autoUpdaterEvent.event === 'update-cancelled' || - autoUpdaterEvent.event === 'error' - ) { - await this.modalController.dismiss(); - } - }); - } + progressInfo = input.required(); formatBytes(bytes: number, decimals = 2): string { if (bytes === 0) return '0 Bytes'; diff --git a/apps/spie-ui/src/app/electron.service.spec.ts b/apps/spie-ui/src/app/services/electron.service.spec.ts similarity index 100% rename from apps/spie-ui/src/app/electron.service.spec.ts rename to apps/spie-ui/src/app/services/electron.service.spec.ts diff --git a/apps/spie-ui/src/app/electron.service.ts b/apps/spie-ui/src/app/services/electron.service.ts similarity index 100% rename from apps/spie-ui/src/app/electron.service.ts rename to apps/spie-ui/src/app/services/electron.service.ts diff --git a/apps/spie-ui/src/app/services/toaster.service.ts b/apps/spie-ui/src/app/services/toaster.service.ts new file mode 100644 index 0000000..c57bb9d --- /dev/null +++ b/apps/spie-ui/src/app/services/toaster.service.ts @@ -0,0 +1,37 @@ +import { Injectable, inject } from '@angular/core'; +import { ToastController } from '@ionic/angular/standalone'; + +@Injectable({ + providedIn: 'root', +}) +export class ToasterService { + private readonly toastController = inject(ToastController); + + private async presentToast( + header: string, + message?: string, + color?: string + ): Promise { + const toast = await this.toastController.create({ + header, + message, + duration: 3000, + position: 'bottom', + color, + }); + + await toast.present(); + } + + async presentInfoToast(header: string): Promise { + await this.presentToast(header, undefined); + } + + async presentWarningToast(message: string): Promise { + await this.presentToast('Warning', message, 'warning'); + } + + async presentErrorToast(error: unknown): Promise { + await this.presentToast('Error', `${error}`, 'danger'); + } +} diff --git a/apps/spie-ui/src/app/update-modal.component.html b/apps/spie-ui/src/app/update-modal.component.html deleted file mode 100644 index 53a4136..0000000 --- a/apps/spie-ui/src/app/update-modal.component.html +++ /dev/null @@ -1,54 +0,0 @@ - - - Transfer Progress - - @if (progressInfo().percent===0) { - - } - - - - - - - - - Total Size - {{ formatBytes(progressInfo().total) }} - - - - - - Transferred - {{ - formatBytes(progressInfo().transferred) - }} - - - - - - Speed - {{ formatBytes(progressInfo().bytesPerSecond) }}/s - - - - - - Progress - {{ progressInfo().percent | number : '1.2-2' }}% - - - - - - Time Remaining - {{ getEstimatedTime() }} - - - diff --git a/apps/spie-ui/src/global.scss b/apps/spie-ui/src/global.scss index a302fd3..97959f7 100644 --- a/apps/spie-ui/src/global.scss +++ b/apps/spie-ui/src/global.scss @@ -33,9 +33,3 @@ */ @import '@ionic/angular/css/palettes/dark.system.css'; - -ion-modal#update-modal.md, -ion-modal#update-modal.ios { - --width: 400px; - --height: 325px; -} diff --git a/apps/spie-ui/src/index.html b/apps/spie-ui/src/index.html index 79a976e..05954d0 100644 --- a/apps/spie-ui/src/index.html +++ b/apps/spie-ui/src/index.html @@ -16,7 +16,6 @@ - diff --git a/apps/spie-ui/src/main.ts b/apps/spie-ui/src/main.ts index 7ad24e8..0af307a 100644 --- a/apps/spie-ui/src/main.ts +++ b/apps/spie-ui/src/main.ts @@ -1,8 +1,22 @@ import { bootstrapApplication } from '@angular/platform-browser'; -import { provideIonicAngular } from '@ionic/angular/standalone'; +import { + PreloadAllModules, + RouteReuseStrategy, + provideRouter, + withPreloading, +} from '@angular/router'; +import { + IonicRouteStrategy, + provideIonicAngular, +} from '@ionic/angular/standalone'; import { AppComponent } from './app/app.component'; +import { routes } from './app/app.routes'; bootstrapApplication(AppComponent, { - providers: [provideIonicAngular()], + providers: [ + { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, + provideIonicAngular(), + provideRouter(routes, withPreloading(PreloadAllModules)), + ], }); diff --git a/apps/spie-ui/src/theme/variables.scss b/apps/spie-ui/src/theme/variables.scss index 6146c39..a5c662f 100644 --- a/apps/spie-ui/src/theme/variables.scss +++ b/apps/spie-ui/src/theme/variables.scss @@ -1,2 +1,67 @@ // For information on how to create your own theme, please see: // http://ionicframework.com/docs/theming/ + +:root { + --ion-color-primary: #0054e9; + --ion-color-primary-rgb: 0, 84, 233; + --ion-color-primary-contrast: #ffffff; + --ion-color-primary-contrast-rgb: 255, 255, 255; + --ion-color-primary-shade: #004acd; + --ion-color-primary-tint: #1a65eb; + + --ion-color-secondary: #0163aa; + --ion-color-secondary-rgb: 1, 99, 170; + --ion-color-secondary-contrast: #ffffff; + --ion-color-secondary-contrast-rgb: 255, 255, 255; + --ion-color-secondary-shade: #015796; + --ion-color-secondary-tint: #1a73b3; + + --ion-color-tertiary: #6030ff; + --ion-color-tertiary-rgb: 96, 48, 255; + --ion-color-tertiary-contrast: #ffffff; + --ion-color-tertiary-contrast-rgb: 255, 255, 255; + --ion-color-tertiary-shade: #542ae0; + --ion-color-tertiary-tint: #7045ff; + + --ion-color-success: #2dd55b; + --ion-color-success-rgb: 45, 213, 91; + --ion-color-success-contrast: #000000; + --ion-color-success-contrast-rgb: 0, 0, 0; + --ion-color-success-shade: #28bb50; + --ion-color-success-tint: #42d96b; + + --ion-color-warning: #ffc409; + --ion-color-warning-rgb: 255, 196, 9; + --ion-color-warning-contrast: #000000; + --ion-color-warning-contrast-rgb: 0, 0, 0; + --ion-color-warning-shade: #e0ac08; + --ion-color-warning-tint: #ffca22; + + --ion-color-danger: #c5000f; + --ion-color-danger-rgb: 197, 0, 15; + --ion-color-danger-contrast: #ffffff; + --ion-color-danger-contrast-rgb: 255, 255, 255; + --ion-color-danger-shade: #ad000d; + --ion-color-danger-tint: #cb1a27; + + --ion-color-light: #f6f8fc; + --ion-color-light-rgb: 246, 248, 252; + --ion-color-light-contrast: #000000; + --ion-color-light-contrast-rgb: 0, 0, 0; + --ion-color-light-shade: #d8dade; + --ion-color-light-tint: #f7f9fc; + + --ion-color-medium: #5f5f5f; + --ion-color-medium-rgb: 95, 95, 95; + --ion-color-medium-contrast: #ffffff; + --ion-color-medium-contrast-rgb: 255, 255, 255; + --ion-color-medium-shade: #545454; + --ion-color-medium-tint: #6f6f6f; + + --ion-color-dark: #2f2f2f; + --ion-color-dark-rgb: 47, 47, 47; + --ion-color-dark-contrast: #ffffff; + --ion-color-dark-contrast-rgb: 255, 255, 255; + --ion-color-dark-shade: #292929; + --ion-color-dark-tint: #444444; +} diff --git a/libs/types/src/lib/electron.d.ts b/libs/types/src/lib/electron.d.ts index 219e36c..7049522 100644 --- a/libs/types/src/lib/electron.d.ts +++ b/libs/types/src/lib/electron.d.ts @@ -18,8 +18,11 @@ export type AutoUpdaterEvent = | { event: 'update-cancelled'; updateInfo: UpdateInfo }; export type Delimiter = 'none' | 'cr' | 'lf' | 'crlf'; + export type Encoding = 'ascii' | 'hex'; + export type SerialPortEventType = 'error' | 'open' | 'close' | 'data' | 'drain'; + export type SerialPortEvent = | { event: 'error'; error: Error } | { event: 'open' } @@ -27,6 +30,16 @@ export type SerialPortEvent = | { event: 'data'; data: string } | { event: 'drain' }; +export interface SerialPortAPI { + list: () => Promise; + open: (openOptions: OpenOptions) => Promise; + close: () => Promise; + write: (data: string, encoding: Encoding) => Promise; + isOpen: () => Promise; + setReadEncoding: (encoding: Encoding) => Promise; + onEvent: (callback: (serialPortEvent: SerialPortEvent) => void) => () => void; +} + export interface ElectronAPI { platform: string; quit: (code: number) => void; @@ -36,17 +49,7 @@ export interface ElectronAPI { onUpdateEvent: ( callback: (autoUpdaterEvent: AutoUpdaterEvent) => void ) => () => void; - serialPort: { - list: () => Promise; - open: (openOptions: OpenOptions) => Promise; - close: () => Promise; - write: (data: string, encoding: Encoding) => Promise; - isOpen: () => Promise; - setReadEncoding: (encoding: Encoding) => Promise; - onEvent: ( - callback: (serialPortEvent: SerialPortEvent) => void - ) => () => void; - }; + serialPort: SerialPortAPI; } declare global { diff --git a/nx.json b/nx.json index 54308db..7960183 100644 --- a/nx.json +++ b/nx.json @@ -17,7 +17,7 @@ ], "sharedGlobals": ["{workspaceRoot}/.github/workflows/ci.yml"] }, - "nxCloudId": "6743e533cce742514f4f6ba9", + "nxCloudId": "6747c9a40a054316587578e9", "targetDefaults": { "@angular-devkit/build-angular:application": { "cache": true,