From 0eb644eec523375b42e8413666390f557f7e6b5b Mon Sep 17 00:00:00 2001 From: Scott Bezek Date: Sun, 17 Dec 2023 17:15:05 -0800 Subject: [PATCH] Lots of improvements, and initial github actions --- .github/workflows/js.yml | 52 +++++++++++ .gitignore | 1 + next.config.js | 20 +++- src/app/dp100/dp100.ts | 93 ++++++++++++++---- src/app/dp100/hid-reports.ts | 2 - src/app/page.tsx | 176 +++++++++++++++++++++++++++-------- src/app/webhid.ts | 2 +- 7 files changed, 285 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/js.yml diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml new file mode 100644 index 0000000..1f987fb --- /dev/null +++ b/.github/workflows/js.yml @@ -0,0 +1,52 @@ +name: JS + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build + - name: Setup Pages + uses: actions/configure-pages@v3 + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + path: './out' + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + if: github.repository == 'scottbez1/webdp100' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 diff --git a/.gitignore b/.gitignore index b90a368..fde667a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .next +out \ No newline at end of file diff --git a/next.config.js b/next.config.js index 767719f..266ff61 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,22 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {} + +const isGithubActions = process.env.GITHUB_ACTIONS || false + +let assetPrefix = '' +let basePath = '' + +if (isGithubActions) { + // trim off `/` + const repo = process.env.GITHUB_REPOSITORY.replace(/.*?\//, '') + + assetPrefix = `/${repo}/` + basePath = `/${repo}` +} + +const nextConfig = { + assetPrefix, + basePath, + output: 'export', +} module.exports = nextConfig diff --git a/src/app/dp100/dp100.ts b/src/app/dp100/dp100.ts index 063353a..76b60d6 100644 --- a/src/app/dp100/dp100.ts +++ b/src/app/dp100/dp100.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { FRAME_FUNC, Frame, inputReportDataToFrame } from "./hid-reports"; import { BasicInfo, BasicSet, basicInfoFromFrame, basicSetFrameData, basicSetFromFrame } from "./frame-data"; import { crc16modbus } from "crc"; @@ -11,20 +11,37 @@ export class DP100 { this.device = device } + private queue: Array<() => Promise> = [] + private runningTask: boolean = false - // TODO: Leaking memory? - private pendingTask: Promise = Promise.resolve() + private enqueue(task: () => Promise) { + this.queue.push(task) + this.serviceQueue() + } + private async serviceQueue() { + if (this.runningTask) { + return + } + this.runningTask = true; + try { + let task + while (task = this.queue.shift()) { + await task() + } + } finally { + this.runningTask = false + } + } private async sendFrameForResponse(frame: Frame, expectedFunctionResponse: number) { return new Promise((reqResolve, reqReject) => { - this.pendingTask = this.pendingTask.then(async () => { - console.log('starting new task') + this.enqueue(async () => { return new Promise((taskResolve) => { // TODO: timeout to prevent queue backup const eventListener = (e: HIDInputReportEvent) => { - console.log(e.device.productName + ": got input report " + e.reportId); + console.debug(e.device.productName + ": got input report " + e.reportId); const frame = inputReportDataToFrame(e.data.buffer) if (frame !== null && frame.functionType === expectedFunctionResponse) { success(frame) @@ -35,12 +52,12 @@ export class DP100 { reqResolve(result) taskResolve() } - const failure = (error: any) => { - this.device.removeEventListener('inputreport', eventListener) - reqReject(error) - taskResolve() - } - console.log('registering listener') + // const failure = (error: any) => { + // console.log('unregistering listener') + // this.device.removeEventListener('inputreport', eventListener) + // reqReject(error) + // taskResolve() + // } this.device.addEventListener('inputreport', eventListener) this.sendFrame(frame) }) @@ -54,7 +71,7 @@ export class DP100 { frame.functionType, frame.sequence, frame.dataLen, - ...frame.data, + ...(frame.data as any), // TODO: fix types 0, 0, ]); @@ -63,7 +80,7 @@ export class DP100 { const frameBufferDv = new DataView(frameBuffer.buffer, frameBuffer.byteOffset, frameBuffer.byteLength) frameBufferDv.setUint16(frameBuffer.length - 2, checksum, true); // little-endian - console.log('sendReport', frameBuffer) + console.debug('sendReport', {functionType: frame.functionType}) await this.device.sendReport(0, frameBuffer); } @@ -79,7 +96,7 @@ export class DP100 { } const response = await this.sendFrameForResponse(frame, FRAME_FUNC.FRAME_BASIC_INFO) const basicInfo = basicInfoFromFrame(response) - console.log('basic info', basicInfo) + console.debug('basic info', basicInfo) return basicInfo } @@ -95,7 +112,7 @@ export class DP100 { } const response = await this.sendFrameForResponse(frame, FRAME_FUNC.FRAME_BASIC_SET) const basicSet = basicSetFromFrame(response) - console.log('basic set', basicSet) + console.debug('basic set', basicSet) return basicSet } @@ -111,7 +128,6 @@ export class DP100 { data: frameData, } const response = await this.sendFrameForResponse(frame, FRAME_FUNC.FRAME_BASIC_SET) - console.log('setBasic response', response) return response.data[0] === 1 } } @@ -121,3 +137,46 @@ export const useDP100 = (device: HIDDevice) => { return new DP100(device) }, [device]) } + +type WithTimestamp = T & {_ts: Date} + +export function useInfoSubscription(loadData: () => Promise , delayMs: number): { data: WithTimestamp | null, refresh: () => Promise } { + const [data, setData] = useState | null>(null) + + const mountedRef = useRef(false) + const timeoutRef = useRef | null>(null) + const refresh = useMemo(() => { + return async () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + if (!mountedRef.current) { + return + } + const newData = await loadData() + setData({ + ...newData, + _ts: new Date(), + }) + if (!mountedRef.current) { + return + } + timeoutRef.current = setTimeout(refresh, delayMs) + } + }, [delayMs]) + + useEffect(() => { + mountedRef.current = true + refresh() + return () => { + mountedRef.current = false + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + } + }, [refresh]) + + return { data, refresh } +} diff --git a/src/app/dp100/hid-reports.ts b/src/app/dp100/hid-reports.ts index 02ab7be..a079924 100644 --- a/src/app/dp100/hid-reports.ts +++ b/src/app/dp100/hid-reports.ts @@ -29,7 +29,6 @@ export type Frame = { // TODO: clean up the array/buffer types? export const inputReportDataToFrame = (buf: ArrayBuffer): Frame | null => { const frameData = new Uint8Array(buf) - console.log(frameData); const frameDv = new DataView(frameData.buffer, frameData.byteOffset, frameData.byteLength) const dataLen = frameDv.getUint8(3); @@ -41,7 +40,6 @@ export const inputReportDataToFrame = (buf: ArrayBuffer): Frame | null => { data: frameData.slice(4, 4 + dataLen), } const checksum = frameDv.getUint16(4 + dataLen, true) - console.log('got frame', frame) const computedChecksum = crc16modbus(frameData.slice(0, 4 + dataLen)) if (computedChecksum !== checksum) { console.warn('checksum mismatch in received frame', { expected: computedChecksum, received: checksum }) diff --git a/src/app/page.tsx b/src/app/page.tsx index 95576d5..c7e367e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,8 @@ import styles from './page.module.css' import { useRequestWebHIDDevice } from './webhid' -import { DP100_USB_INFO, useDP100 } from './dp100/dp100'; -import { useEffect, useState } from 'react'; +import { DP100_USB_INFO, useDP100, useInfoSubscription } from './dp100/dp100'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { BasicInfo, BasicSet } from './dp100/frame-data'; const filters = [DP100_USB_INFO]; @@ -22,11 +22,35 @@ export default function Home() { interface IDP100Props { device: HIDDevice, } + +const sleep = async (delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs)) const DP100: React.FC = ({device}) => { const dp100 = useDP100(device) - const [basicInfo, setBasicInfo] = useState(null) - const [basicSet, setBasicSet] = useState(null) + const {data: basicInfo, refresh: refreshBasicInfo } = useInfoSubscription(() => dp100.getBasicInfo(), 150) + const {data: basicSet, refresh: refreshBasicSet } = useInfoSubscription(() => dp100.getCurrentBasic(), 2000) + + const setBasic = async (data: BasicSet) => { + if (!await dp100.setBasic(data)) { + console.warn('setBasic failed') + return + } + await sleep(100) + refreshBasicSet() + } + + const updateBasic = async (updates: Partial) => { + if (basicSet === null) { + throw new Error('Can\'t update before receiving state') + } + return setBasic({ + ...basicSet, + ...updates, + }) + } + + const modeStr = basicInfo === null ? 'unknown' : + basicInfo.out_mode === 2 ? 'OFF' : basicInfo.out_mode === 1 ? 'CV' : basicInfo.out_mode === 0 ? 'CC' : basicInfo.out_mode === 130 ? 'UVP' : 'unknown' return ( <> @@ -34,56 +58,128 @@ const DP100: React.FC = ({device}) => { Connected to {device.productName}
- -
-
- + { basicSet && ( + <> + + )}

- -
-
- {basicInfo && ( - + {basicSet && basicInfo && ( +
+ + + + + + - - - - - + + + + + + + + + + + + + + + + + + + +
SetOut
IN{(basicInfo.vin / 1000).toFixed(2)}V
OUT{(basicInfo.vout / 1000).toFixed(2)}V
OUT_I{(basicInfo.iout / 1000).toFixed(3)}A
out_max{(basicInfo.vo_max / 1000).toFixed(2)}V
MODE{basicInfo.out_mode === 2 ? 'OFF' : basicInfo.out_mode === 1 ? 'CV' : basicInfo.out_mode === 0 ? 'CC' : basicInfo.out_mode === 130 ? 'UVP' : 'unknown'}
Status + + + {modeStr}
Voltage updateBasic({vo_set: Number.parseFloat(v) * 1000})} />{(basicInfo.vout / 1000).toFixed(2)}V
Current updateBasic({io_set: Number.parseFloat(v) * 1000})} />{(basicInfo.iout / 1000).toFixed(3)}A
Data
)} +

- -
-
- {basicSet && ( - + {basicInfo && ( +
- - + +
VSET{(basicSet.vo_set / 1000).toFixed(2)}V
ISET{(basicSet.io_set / 1000).toFixed(3)}A
V IN{(basicInfo.vin / 1000).toFixed(2)}V
v_out_max{(basicInfo.vo_max / 1000).toFixed(2)}V
)}
+
) -} \ No newline at end of file +} + +type EditableProps = { + value: string, + suffix: string, + onSave: (v: string) => void, +} +const Editable: React.FC = ({value, suffix, onSave}) => { + const [editing, setEditing] = useState(false) + const [draftValue, setDraftValue] = useState("") + + const save = () => { + onSave(draftValue); + setEditing(false); + } + const cancel = () => { + setEditing(false); + } + + return editing ? (
+ setDraftValue(e.target.value)} + autoFocus + onFocus={(e) => e.target.select()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + save() + } else if (e.key === 'Escape') { + cancel() + } + }} + /> + { suffix } +    + + +
) : (
{ + setDraftValue(value); + setEditing(true); + }}>{value}{suffix}
) +} + +type UpdateIndicatorProps = { + data: any +} +const UpdateIndicator: React.FC = ({data}) => { + const [visible, setVisible] = useState(false) + + const timeoutRef = useRef | null>(null) + useMemo(() => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current) + } + setVisible(true); + timeoutRef.current = setTimeout(() => setVisible(false), 50); + }, [data]) + + return visible ? <>🔵 : <>⚪ +} diff --git a/src/app/webhid.ts b/src/app/webhid.ts index 05ef9d5..0d931f3 100644 --- a/src/app/webhid.ts +++ b/src/app/webhid.ts @@ -46,7 +46,7 @@ export const useRequestWebHIDDevice = ({requestOptions}: {requestOptions: HIDDev }, [device]) return { - requestAndOpen: 'hid' in navigator ? requestAndOpen : null, + requestAndOpen: typeof window !== 'undefined' && 'hid' in navigator ? requestAndOpen : null, device, errorMessage, }