diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ae84562..5732299 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,22 +1,22 @@ -import './globals.css' -import type { Metadata } from 'next' -import { Inter } from 'next/font/google' +import "./globals.css"; +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; -const inter = Inter({ subsets: ['latin'] }) +const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} + title: "Create Next App", + description: "Generated by create next app", +}; export default function RootLayout({ children, }: { - children: React.ReactNode + children: React.ReactNode; }) { return ( {children} - ) + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f3b086..dc66abd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,113 +1,326 @@ -import Image from 'next/image' +"use client"; + +import Image from "next/image"; +import { + TParsedDMMResponse, + debounceReadingFunc, + parseDMMBuffer, + startBluetoothCoonection as startBluetoothConnection, +} from "@/helpers/parseDMM"; +import { startBuzz } from "@/helpers/buzzSound"; +import { + MutableRefObject, + useEffect, + useRef, + useState, + Dispatch, + SetStateAction, +} from "react"; + +export function SpeakerOnIcon() { + return ( + + + + ); +} + +export function SpeakerOffIcon() { + return ( + + + + ); +} + +export function EyeIcon() { + return ( + + + + + ); +} + +export function MegaphoneIcon() { + return ( + + + + ); +} + +export function useBuzzSound() {} + +export function useStateWithRef( + initialValue: TState, +): [TState, Dispatch>, MutableRefObject] { + const [value, setValue] = useState(initialValue); + const valueRef = useRef(initialValue); + valueRef.current = value; + + return [value, setValue, valueRef]; +} export default function Home() { - return ( -
-
-

- Get started by editing  - src/app/page.tsx -

- -
+ const [printedValue, setPrintedValue] = useState( + undefined, + ); + const [liveValue, setLiveValue] = useState(undefined); + const [isMute, setIsMute, isMuteRef] = useStateWithRef(false); + const stopBuzzRef = useRef<(() => void) | undefined>(undefined); -
+ let [selectedVoice, setSelectedVoice, selectedVoiceRef] = useStateWithRef< + string | undefined + >(undefined); + let [allVoices, setAllVoices] = useState(); + let multimeterBluetoothDeviceRef = useRef( + undefined, + ); + let multimeterDisconnectRef = useRef<(() => void) | undefined>(undefined); + + const [showDisconnect, setShowDisconnect] = useState(false); + const showConnect = !showDisconnect; + + function resetStatusAfterDisconnect() { + if (multimeterBluetoothDeviceRef.current !== undefined) { + // Only announce once + speak("Multimeter disconnected"); + } + multimeterBluetoothDeviceRef.current = undefined; + multimeterDisconnectRef.current = undefined; + setLiveValue(undefined); + setPrintedValue(undefined); + setShowDisconnect(false); + } + + function speak(value: string) { + let speech = new SpeechSynthesisUtterance(); + speech.lang = "en"; + speech.text = value; + speech.voice = + allVoices?.find( + ({ voiceURI }) => voiceURI === selectedVoiceRef.current, + ) || null; + speech.volume = isMuteRef.current ? 0 : 1; + window.speechSynthesis.speak(speech); + } + + function handleConnectClick() { + const debouncedLogAndSpeak = debounceReadingFunc((value) => { + value = value as string; + setPrintedValue(value); + console.log(value); + speak(value.replace("-", "Minus ")); + }, 700); + + function handleBuzz(parsedDMMresponse: TParsedDMMResponse) { + const { isBuzz, isDiode } = parsedDMMresponse; + // If BUZZ sign & not DIODE sign & not mute + if (isBuzz && !isDiode && !isMuteRef.current) { + // Multimeter is buzzing, reciprocate + const { stop } = startBuzz(); + stopBuzzRef.current = stop; + } else { + stopBuzzRef.current?.(); + } + } + + function onNotify(bufferAsArray: number[]) { + const parsedDMMresponse = parseDMMBuffer(bufferAsArray); + const printValue = parsedDMMresponse.toString(); + // Set live value + setLiveValue(printValue); + + // Debounced announce value + debouncedLogAndSpeak(printValue); + + // Buzz, if needed + handleBuzz(parsedDMMresponse); + } + startBluetoothConnection({ + onDisconnect: () => resetStatusAfterDisconnect(), + onNotify, + }).then((obj) => { + multimeterBluetoothDeviceRef.current = obj.multimeterBluetoothDevice; + multimeterDisconnectRef.current = obj.disconnect; + setShowDisconnect(true); + speak("Connected"); + }); + } + + function handleDisconnectClick() { + if (multimeterDisconnectRef.current) { + console.log("Calling disconnect..."); + multimeterDisconnectRef.current(); + + resetStatusAfterDisconnect(); + } else { + console.log("Disconnect is not set"); + } + } + + useEffect(() => { + function _updateAllVoices() { + // Get List of Voices + const tempAllVoices = window.speechSynthesis.getVoices(); + const allEnglishVoices = tempAllVoices.filter(({ lang }) => + lang.startsWith("en"), + ); + setAllVoices(allEnglishVoices); + + if (allEnglishVoices.length > 0) { + // Initially set the First Voice in the Array. + setSelectedVoice(allEnglishVoices[0].name); + } + } + + _updateAllVoices(); + window.speechSynthesis.onvoiceschanged = () => { + _updateAllVoices(); + }; + }, [setSelectedVoice]); + + function handleMuteClick() { + setIsMute(true); + } + + function handleUnmuteClick() { + setIsMute(false); + } + + return ( +
+
Next.js Logo +

Multimeter Speaker

- - +
+ {showConnect ? ( + + ) : ( + + )} +
+
+ + {!isMute ? ( + + ) : ( + + )}
- ) + ); } diff --git a/src/helpers/bluetooth.ts b/src/helpers/bluetooth.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/helpers/buzzSound.ts b/src/helpers/buzzSound.ts new file mode 100644 index 0000000..dd82c49 --- /dev/null +++ b/src/helpers/buzzSound.ts @@ -0,0 +1,57 @@ +// The browser will limit the number of concurrent audio contexts +// So be sure to re-use them whenever you can +const myAudioContext = new AudioContext(); +let lastOscillatorNode: OscillatorNode | undefined = undefined; + +/** + * Helper function to emit a beep sound in the browser using the Web Audio API. + * + * @param {number} duration - The duration of the beep sound in milliseconds. + * @param {number} frequency - The frequency of the beep sound. + * @param {number} volume - The volume of the beep sound. + * + * @returns {Promise} - A promise that resolves when the beep sound is finished. + */ +export function startBuzz({ + duration = 500, + frequency = 2800, + volume = 70, + onEnd = () => {}, +} = {}) { + if (lastOscillatorNode !== undefined) { + lastOscillatorNode.stop(myAudioContext.currentTime + duration * 0.001); + return { + stop: () => lastOscillatorNode?.stop(), + }; + } else { + let oscillatorNode = myAudioContext.createOscillator(); + let gainNode = myAudioContext.createGain(); + oscillatorNode.connect(gainNode); + + // Set the oscillator frequency in hertz + oscillatorNode.frequency.value = frequency; + + // Set the type of oscillator + oscillatorNode.type = "sine"; + gainNode.connect(myAudioContext.destination); + + // Set the gain to the volume + gainNode.gain.value = volume * 0.01; + + // Start audio with the desired duration + oscillatorNode.start(myAudioContext.currentTime); + oscillatorNode.stop(myAudioContext.currentTime + duration * 0.001); + + // Resolve the promise when the sound is finished + oscillatorNode.onended = () => { + lastOscillatorNode = undefined; + onEnd(); + }; + + lastOscillatorNode = oscillatorNode; + + return { + stop: () => oscillatorNode.stop(), + }; + } +} diff --git a/src/helpers/constants.ts b/src/helpers/constants.ts new file mode 100644 index 0000000..dbc5b7c --- /dev/null +++ b/src/helpers/constants.ts @@ -0,0 +1,80 @@ +export const DEFAULT_SERVICE_UUID = "0000fff0-0000-1000-8000-00805f9b34fb"; +export const DEFAULT_CHARACTERISTIC_UUID = + "0000fff4-0000-1000-8000-00805f9b34fb"; + +// eslint-disable-next-line max-len +export const XOR_KEYS = [ + parseInt("41", 16), + parseInt("21", 16), + parseInt("73", 16), + parseInt("55", 16), + parseInt("a2", 16), + parseInt("c1", 16), + parseInt("32", 16), + parseInt("71", 16), + parseInt("66", 16), + parseInt("aa", 16), + parseInt("3b", 16), +]; + +export const CHAR_MAP = { + "0": [1, 1, 1, 0, 1, 1, 1].join(""), + "1": [0, 0, 1, 0, 0, 1, 0].join(""), + "2": [1, 0, 1, 1, 1, 0, 1].join(""), + "3": [1, 0, 1, 1, 0, 1, 1].join(""), + "4": [0, 1, 1, 1, 0, 1, 0].join(""), + "5": [1, 1, 0, 1, 0, 1, 1].join(""), + "6": [1, 1, 0, 1, 1, 1, 1].join(""), + "7": [1, 0, 1, 0, 0, 1, 0].join(""), + "8": [1, 1, 1, 1, 1, 1, 1].join(""), + "9": [1, 1, 1, 1, 0, 1, 1].join(""), + A: [1, 1, 1, 1, 1, 1, 0].join(""), + U: [0, 0, 0, 0, 1, 1, 1].join(""), + T: [0, 1, 0, 1, 1, 0, 1].join(""), + O: [0, 0, 0, 1, 1, 1, 1].join(""), + L: [0, 1, 0, 0, 1, 0, 1].join(""), // Part of "OL" text +}; + +export type KNOWN_CHARS = keyof typeof CHAR_MAP; +export type KNOWN_UNKNOWN_CHARS = KNOWN_CHARS | undefined; + +export const LCD_CHAR_BIT_POSITIONS = [ + [24, 25, 36, 37, 26, 38, 39], // 0 + [32, 33, 44, 45, 34, 46, 47], // 1 + [40, 41, 52, 53, 42, 54, 55], // 2 + [48, 49, 60, 61, 50, 62, 63], // 3 +]; + +export const LCD_DECIMAL_POINT_BIT_POSITIONS = [35, 43, 51]; + +export const LCD_SYMBOL_BIT_POSITION_MAP = { + Minus: 27, + Buzz: 28, + Relative: 30, + BatteryLow: 31, + Diode: 56, + Celsius: 57, + Fahrenheit: 58, + Hold: 59, + NanoFarad: 64, + MicroFarad: 65, + MilliFarad: 66, + Farad: 67, + AC: 68, + Percentage: 69, + Min: 70, + Max: 71, + Ampere: 72, + DC: 73, + MilliVolt: 74, + Volt: 75, + MegaOhm: 76, + KiloOhm: 77, + Ohm: 78, + Hertz: 79, + MilliAmp: 84, + MicroAmp: 85, + Auto: 87, +}; + +export type KNOWN_SYMBOLS = keyof typeof LCD_SYMBOL_BIT_POSITION_MAP; diff --git a/src/helpers/parseDMM.ts b/src/helpers/parseDMM.ts new file mode 100644 index 0000000..67088b1 --- /dev/null +++ b/src/helpers/parseDMM.ts @@ -0,0 +1,358 @@ +import { + CHAR_MAP, + DEFAULT_CHARACTERISTIC_UUID, + DEFAULT_SERVICE_UUID, + KNOWN_CHARS, + KNOWN_UNKNOWN_CHARS, + LCD_CHAR_BIT_POSITIONS, + LCD_DECIMAL_POINT_BIT_POSITIONS, + LCD_SYMBOL_BIT_POSITION_MAP, + XOR_KEYS, +} from "./constants"; +import { getBit, getBitAsBoolean, zeroPad } from "./utils"; + +export function parseDMMBuffer(bufferAsNumberArray: number[]) { + const partsXOR = bufferAsNumberArray.map( + (rawVal, index) => rawVal ^ XOR_KEYS[index], + ); + + const partsBin = partsXOR.map((val) => val.toString(2)); + const partsBinPadded = partsBin.map((t) => zeroPad(t, 8)); + + const parsedBooleanString = partsBinPadded.join(""); + + return { + parsedBooleanString, + getBitAsBoolean(binIndex: number) { + return getBitAsBoolean(this.parsedBooleanString, binIndex); + }, + char0: readCharacter(parsedBooleanString, 0), + char1: readCharacter(parsedBooleanString, 1), + char2: readCharacter(parsedBooleanString, 2), + char3: readCharacter(parsedBooleanString, 3), + decimalPointIndex: getDecimalPointIndex(parsedBooleanString), + isMinus: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Minus, + ), + isBuzz: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Buzz, + ), + isRelative: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Relative, + ), + isBatteryLow: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.BatteryLow, + ), + isDiode: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Diode, + ), + isHold: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Hold, + ), + isAuto: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Auto, + ), + isMin: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Min, + ), + isMax: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Max, + ), + isAC: getBitAsBoolean(parsedBooleanString, LCD_SYMBOL_BIT_POSITION_MAP.AC), + isDC: getBitAsBoolean(parsedBooleanString, LCD_SYMBOL_BIT_POSITION_MAP.DC), + isCelsius: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Celsius, + ), + isFahrenheit: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Fahrenheit, + ), + isNanoFarad: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.NanoFarad, + ), + isMicroFarad: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.MicroFarad, + ), + isMilliFarad: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.MilliFarad, + ), + isFarad: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Farad, + ), + isPercentage: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Percentage, + ), + isHertz: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Hertz, + ), + isMilliVolt: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.MilliVolt, + ), + isVolt: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Volt, + ), + isMegaOhm: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.MegaOhm, + ), + isKiloOhm: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.KiloOhm, + ), + isOhm: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Ohm, + ), + isMilliAmp: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.MilliAmp, + ), + isMicroAmp: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.MicroAmp, + ), + isAmpere: getBitAsBoolean( + parsedBooleanString, + LCD_SYMBOL_BIT_POSITION_MAP.Ampere, + ), + get isFullAuto() { + return ( + this.char0 === "A" && + this.char1 === "U" && + this.char2 === "T" && + this.char3 === "O" + ); + }, + get unit() { + if (this.isCelsius) { + return "Celsius"; + } else if (this.isFahrenheit) { + return "Fahrenheit"; + } else if (this.isNanoFarad) { + return "NanoFarad"; + } else if (this.isMicroFarad) { + return "MicroFarad"; + } else if (this.isMilliFarad) { + return "MilliFarad"; + } else if (this.isFarad) { + return "Farad"; + } else if (this.isPercentage) { + return "Percentage"; + } else if (this.isHertz) { + return "Hertz"; + } else if (this.isMilliVolt) { + return "MilliVolt"; + } else if (this.isVolt) { + return "Volt"; + } else if (this.isMegaOhm) { + return "MegaOhm"; + } else if (this.isKiloOhm) { + return "KiloOhm"; + } else if (this.isOhm) { + return "Ohm"; + } else if (this.isMilliAmp) { + return "MilliAmp"; + } else if (this.isMicroAmp) { + return "MicroAmp"; + } else if (this.isAmpere) { + return "Ampere"; + } + }, + get value() { + const parsedChars = [ + this.char0 && parseInt(this.char0), + this.char1 && parseInt(this.char1), + this.char2 && parseInt(this.char2), + this.char3 && parseInt(this.char3), + ]; + let value: number | undefined = undefined; + if ( + parsedChars.every( + (tmpChar) => typeof tmpChar === "number" && !isNaN(tmpChar), + ) + ) { + value = + (parsedChars[0] as number) * 1000 + + (parsedChars[1] as number) * 100 + + (parsedChars[2] as number) * 10 + + (parsedChars[3] as number) * 1; + + if (this.decimalPointIndex !== undefined) { + value = value / 10 ** (3 - this.decimalPointIndex); + } + + if (this.isMinus) { + value = -value; + } + } + return value; + }, + get isOutOfLimit() { + return this.char1 == "0" && this.char2 == "L"; + }, + toString() { + if (this.value !== undefined && this.unit !== undefined) { + return `${this.value} ${this.unit} ${this.isAC ? "(AC)" : ""}${ + this.isDC ? "(DC)" : "" + }`; + } else if (this.isOutOfLimit) { + return `Out of Limit (${this.unit})`; + } + }, + }; +} + +export type TParsedDMMResponse = ReturnType; + +class MultimeterError extends Error {} + +export async function startBluetoothCoonection({ + serviceUUID = DEFAULT_SERVICE_UUID, + characteristicUUID = DEFAULT_CHARACTERISTIC_UUID, + onNotify = printBLENotify, + onDisconnect = () => {}, +} = {}) { + let multimeterBluetoothDevice: BluetoothDevice | null = null; + multimeterBluetoothDevice = await navigator.bluetooth.requestDevice({ + filters: [ + { + services: [serviceUUID], + }, + ], + }); + if (multimeterBluetoothDevice === undefined) { + throw new MultimeterError("Multimeter not selected"); + } + if (multimeterBluetoothDevice.gatt === undefined) { + throw new MultimeterError("BLE: Gatt is not available"); + } + let gatt = await multimeterBluetoothDevice.gatt.connect(); + multimeterBluetoothDevice.addEventListener("gattserverdisconnected", (ev) => { + console.log("Multimeter disconnected"); + onDisconnect(); + }); + + let service = await gatt.getPrimaryService(serviceUUID); + let characteristic = await service.getCharacteristic(characteristicUUID); + + async function notifyCallback() { + const buffer: ArrayBuffer | undefined = characteristic.value?.buffer; + if (buffer == undefined) { + throw new MultimeterError("BLE: Notify buffer is undefined"); + } + const bufferAsArray = [...new Uint8Array(buffer)]; + onNotify(bufferAsArray); + } + + if (characteristic.properties.notify) { + characteristic.addEventListener( + "characteristicvaluechanged", + notifyCallback, + ); + await characteristic.startNotifications(); + } + + return { + multimeterBluetoothDevice, + service, + characteristic, + removeEventListener() { + characteristic.removeEventListener( + "characteristicvaluechanged", + notifyCallback, + ); + }, + disconnect() { + multimeterBluetoothDevice?.gatt?.disconnect(); + onDisconnect(); + }, + }; +} + +const debouncedLog = debounceReadingFunc((value) => console.log(value)); +export function printBLENotify(bufferAsArray: number[]) { + const printValue = parseDMMBuffer(bufferAsArray).toString(); + debouncedLog(printValue); +} + +export function debounceReadingFunc( + callbackFn: (input: string | number) => void, + debounceMillis = 1000, +) { + let timerId: ReturnType | undefined = undefined; + let lastReturnedValue: number | string | undefined = undefined; + let lastNewValue: number | string | undefined = undefined; + + return function debouncedReading(newValue: string | number | undefined) { + if (newValue !== undefined && newValue !== lastNewValue) { + if (timerId !== undefined) { + clearTimeout(timerId); + timerId = undefined; + } + + if (newValue !== lastReturnedValue) { + timerId = setTimeout(() => { + lastReturnedValue = newValue; + timerId = undefined; + + callbackFn(newValue); + }, debounceMillis); + } + } + lastNewValue = newValue; + }; +} + +export function readCharacter( + binString: string, + charIndex: number, +): KNOWN_UNKNOWN_CHARS { + if (charIndex >= LCD_CHAR_BIT_POSITIONS.length) { + throw new Error("Character index is invalid"); + } + const charBitPositions = LCD_CHAR_BIT_POSITIONS[charIndex]; + const receivedCharValueString = charBitPositions + .map((tmpBitPosition) => getBit(binString, tmpBitPosition)) + .join(""); + + const foundKeyValue: [KNOWN_CHARS, string] | undefined = ( + Object.entries(CHAR_MAP) as Array<[KNOWN_CHARS, string]> + ).find(([_, matchingCharValue]) => { + if (matchingCharValue == receivedCharValueString) { + return true; + } + }); + + if (foundKeyValue == undefined) { + return undefined; + } + return foundKeyValue[0]; +} + +export function getDecimalPointIndex(binString: string): number | undefined { + const maybeDecimalPointIndex = LCD_DECIMAL_POINT_BIT_POSITIONS.findIndex( + (tmpBitPosition) => getBitAsBoolean(binString, tmpBitPosition), + ); + if (maybeDecimalPointIndex === -1) { + return undefined; + } + return maybeDecimalPointIndex; +} diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts new file mode 100644 index 0000000..d1800e8 --- /dev/null +++ b/src/helpers/utils.ts @@ -0,0 +1,42 @@ +/** + * Pads number/string with zeros and returns string of required length. + * @example `zeroPad('101', 8)` - converts boolean (string) to 8-sized byte (string) + * @param num Number to pad + * @param length Total length after padding + * @returns zeropadded string + */ +export function zeroPad(num: number | string, length: number): string { + return String(num).padStart(length, "0"); +} + +/** + * Gets bit value from binary string + * @param str Binary string + * @param bitIndex Index of bit to get + * @returns bit value + */ +export function getBit(str: string, bitIndex: number): number { + const value = str[bitIndex]; + if (value == undefined) { + throw new RangeError(`Index:${bitIndex} not found in ${str}`); + } + return Number(value); +} + +/** + * Gets bit value as boolean from binary string + * @param str Binary string + * @param bitIndex Index of bit to get + * @returns bit value as boolean + */ +export function getBitAsBoolean(str: string, bitIndex: number): boolean { + return !!getBit(str, bitIndex); +} + +export function hexStringToNumberArray(rawString: string) { + return rawString.split("-").map((val) => parseInt(val, 16)); +} + +export function hexArrayToNumberArray(rawStrings: string[]) { + return rawStrings.map((val) => parseInt(val, 16)); +}