diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml
new file mode 100644
index 0000000..e2a15b2
--- /dev/null
+++ b/.github/workflows/static.yml
@@ -0,0 +1,43 @@
+# Simple workflow for deploying static content to GitHub Pages
+name: Deploy static content to Pages
+
+on:
+ # Runs on pushes targeting the default branch
+ push:
+ branches: ["main"]
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+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:
+ # Single deploy job since we're just deploying
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ # Upload entire repository
+ path: './UI'
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/UI/index.html b/UI/index.html
new file mode 100644
index 0000000..7fc2088
--- /dev/null
+++ b/UI/index.html
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+ NeoDK control panel
+
+
+
+
+
+
+
+
+
+
Sorry, your browser does not support WebSerial API. Please try in different browser.
+
+
+
+
+
+
NeoDK control panel
+
+
+
+
+
+
+
+
+
+
Connected devices: {{ devices.length }}
+
+
+
+
+ NeoDK ({{ device.state.PlayState }} | {{ device.state.Intensity }}% )
+
+
+ {{ device.state.Intensity }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/UI/neodk.js b/UI/neodk.js
new file mode 100644
index 0000000..fc1bc32
--- /dev/null
+++ b/UI/neodk.js
@@ -0,0 +1,517 @@
+"use strict";
+/**
+ * Represents a instance of NeoDK box that is connected to a serial port.
+ * @class
+ * @copyright 2024 Neostim B.V. All rights reserved.
+ */
+class NeoDK {
+
+ /**
+ * initialize instance
+ * @constructor
+ * @param { Object } dependencies - the dependencies for the NeoDK instance
+ * @param { Object } dependencies.logger - The logger to use; needs to have methods: log, debug.
+ * @param { NeoDK.State } dependencies.state - The custom state class, if you want to override its setters to be able to react on state changes
+ * @default console
+ */
+ constructor({ logger = console, state = new NeoDK.State() }) {
+ this.#rx_frame = new Uint8Array(NeoDK.#StructureSize.FrameHeader + NeoDK.#StructureSize.MaxPayload);
+
+ this.logger = logger;
+ this.state = state;
+ }
+
+ /**
+ * Check if browser supports WebSerial API
+ * @returns true if browser supports WebSerial API, false otherwise
+ */
+ static browserSupported() { return 'serial' in navigator; }
+
+ /**
+ * Enum for allowed play states that can be set
+ * @public
+ * @enum
+ * @readonly
+ */
+ static ChangeStateCommand = {
+ play: 'play',
+ pause: 'pause',
+ stop: 'stop'
+ }
+
+ // Public methods
+
+ /**
+ * Method to select pattern to play
+ * @public
+ * @param {string} name name of the pattern to select
+ */
+ selectPattern(name) {
+ let enc_name = new TextEncoder().encode(name);
+ const array = Array.from(enc_name); // Convert to regular array
+ array.unshift(NeoDK.#Encoding.UTF8_1Len, enc_name.length); // Use unshift
+ enc_name = Uint8Array.from(array);
+ this.#sendAttrWriteRequest(this.#the_writer, NeoDK.#AttributeId.CurrentPatternName, enc_name);
+ }
+
+ /**
+ * Method to set play state of the box
+ * @public
+ * @param {ChangeStateCommand} state one of the accepted play states from ChangeStateCommand
+ */
+ setPlayState(state) {
+ let enc_state = new TextEncoder().encode(state);
+ const array = Array.from(enc_state); // Convert to regular array
+ array.unshift(NeoDK.#Encoding.UTF8_1Len, enc_state.length); // Use unshift
+ enc_state = Uint8Array.from(array);
+ this.#sendAttrWriteRequest(this.#the_writer, NeoDK.#AttributeId.PlayPauseStop, enc_state);
+ }
+
+ /**
+ * Method to set intensity of output
+ * @public
+ * @param {number} intensity the intensity between 0 and 100
+ */
+ setIntensity(intensity) {
+ this.#sendAttrWriteRequest(this.#the_writer, NeoDK.#AttributeId.IntensityPercent, new Uint8Array([NeoDK.#Encoding.UnsignedInt1, intensity]));
+ }
+
+ /**
+ * Method to get the port from browser
+ * User will be prompted to select a port that box is connected to
+ * @public
+ * @param {Event} evt
+ */
+ async selectPort(evt) {
+ const filters = [
+ { usbVendorId: 0x0403 }, // FTDI
+ { usbVendorId: 0x067b }, // Prolific
+ { usbVendorId: 0x10c4 }, // Silicon Labs
+ { usbVendorId: 0x1a86 }, // WCH (CH340 chip)
+ { usbVendorId: 0x16d0, usbProductId: 0x12ef }
+ ];
+ var port = await navigator.serial.requestPort({ filters });
+ port.onconnect = () => {
+ this.logger.log('Connected', port.getInfo());
+ }
+ port.ondisconnect = () => {
+ this.logger.log('Disconnected', port.getInfo());
+ writer.releaseLock();
+ writer = null;
+ }
+ return await this.#usePort(port);
+ }
+
+
+ /**
+ * Structure that represents play state of NeoDK
+ */
+ static State = class {
+ constructor() {
+ this._playState = NeoDK.#playStates[0];
+ this._intensity = 0;
+ this._currentPattern = '';
+ this._availablePatterns = [];
+ }
+
+
+ get PlayState() {
+ return this._playState;
+ }
+ set PlayState(value) {
+ this._playState = value;
+ }
+
+ get Intensity() {
+ return this._intensity;
+ }
+ set Intensity(value) {
+ this._intensity = value;
+ }
+
+ get CurrentPattern() {
+ return this._currentPattern;
+ }
+ set CurrentPattern(value) {
+ this._currentPattern = value;
+ }
+
+ get AvailablePatterns() {
+ return this._availablePatterns;
+ }
+ set AvailablePatterns(value) {
+ this._availablePatterns = value;
+ }
+ }
+
+ // protocol constants
+
+ /**
+ * NeoDK Protocol: Structure Sizes
+ * @private
+ * @enum
+ * @readonly
+ */
+ static #StructureSize = {
+ FrameHeader: 8,
+ MaxPayload: 512,
+ PacketHeader: 6,
+ AttributeAction: 6
+ };
+
+ /**
+ * NeoDK Protocol: Frame Types
+ * @private
+ * @enum
+ * @readonly
+ */
+ static #FrameType = {
+ None: 0,
+ Ack: 1,
+ Sync: 3,
+ Data: 4
+ };
+
+ /**
+ * NeoDK Protocol: Network Service Types
+ * @private
+ * @enum
+ * @readonly
+ */
+ static #NST = {
+ Debug: 0,
+ Datagram: 1
+ };
+
+ /**
+ * NeoDK Protocol: Attribute Identifiers
+ * @private
+ * @enum
+ * @readonly
+ */
+ static #AttributeId = {
+ AllPatternNames: 5,
+ CurrentPatternName: 6,
+ IntensityPercent: 7,
+ PlayPauseStop: 8
+ };
+
+ /**
+ * NeoDK Protocol: Encodings
+ * @private
+ * @enum
+ * @readonly
+ */
+ static #Encoding = {
+ UnsignedInt1: 4,
+ UTF8_1Len: 12,
+ Array: 22,
+ EndOfContainer: 24
+ }
+
+ /**
+ * NeoDK Protocol: OP Codes (request types)
+ * @private
+ * @enum
+ * @readonly
+ */
+ static #OPCode = {
+ ReadRequest: 2,
+ SubscribeRequest: 3,
+ ReportData: 5,
+ WriteRequest: 6,
+ InvokeRequest: 8
+ };
+
+
+
+ /**
+ * NeoDK Protocol: Play states
+ * @private
+ * @enum
+ * @readonly
+ */
+ static #playStates = {
+ "0": "undefined",
+ "1": "stopped",
+ "2": "paused",
+ "3": "playing"
+ };
+
+ // private fields on NeoDK instance
+ #rx_frame;
+ #rx_nb = 0;
+ #incoming_payload_size = 0;
+ #the_writer = null;
+ #tx_seq_nr = 0;
+ #transaction_id = 1959;
+
+ // private methods
+
+ #initFrame(payload_size, frame_type, service_type, seq) {
+ const frame = new Uint8Array(NeoDK.#StructureSize.FrameHeader + payload_size);
+ frame[0] = (service_type << 4) | (frame_type << 1);
+ frame[1] = seq << 3;
+ frame[2] = (payload_size >> 8) & 0xff;
+ frame[3] = payload_size & 0xff;
+ frame[4] = 0;
+ return frame;
+ }
+
+
+ #crcFrame(frame) {
+ frame[5] = this.#crc8_ccitt(0, frame, 5);
+ let crc16 = this.#crc16_ccitt(0xffff, frame, 6);
+ crc16 = this.#crc16_ccitt(crc16, frame.slice(NeoDK.#StructureSize.FrameHeader), frame.length - NeoDK.#StructureSize.FrameHeader);
+ frame[6] = (crc16 >> 8);
+ frame[7] = crc16 & 0xff;
+ return frame;
+ }
+
+
+ #makeAckFrame(service_type, ack) {
+ const frame = this.#initFrame(0, NeoDK.#FrameType.Ack, service_type, 0);
+ frame[0] |= 1;
+ frame[1] |= (ack & 0x7);
+ return this.#crcFrame(frame);
+ }
+
+
+ #makeSyncFrame(service_type) {
+ return this.#crcFrame(this.#initFrame(0, NeoDK.#FrameType.Sync, service_type, this.#tx_seq_nr++));
+ }
+
+
+ // not used... todo delete?
+ #makeCommandFrame(cmnd_str) {
+ const enc_cmnd = new TextEncoder().encode(cmnd_str);
+ const frame = this.#initFrame(enc_cmnd.length, NeoDK.#FrameType.Data, NeoDK.#NST.Debug, this.#tx_seq_nr++);
+ frame.set(enc_cmnd, FRAME_HEADER_SIZE);
+ return this.#crcFrame(frame);
+ }
+
+
+ #makeRequestPacketFrame(trans_id, request_type, attribute_id, data) {
+ let packet_size = NeoDK.#StructureSize.PacketHeader + NeoDK.#StructureSize.AttributeAction;
+ if (data !== null) packet_size += data.length;
+ const frame = this.#initFrame(packet_size, NeoDK.#FrameType.Data, NeoDK.#NST.Datagram, this.#tx_seq_nr++);
+ let offset = NeoDK.#StructureSize.FrameHeader;
+ // Initialise the packet header - to all zeroes, for now.
+ for (let i = 0; i < NeoDK.#StructureSize.PacketHeader; i++) frame[offset++] = 0x00;
+ frame[offset++] = trans_id & 0xff;
+ frame[offset++] = (trans_id >> 8) & 0xff;
+ frame[offset++] = request_type & 0xff;
+ frame[offset++] = 0x00;
+ frame[offset++] = attribute_id & 0xff;
+ frame[offset++] = (attribute_id >> 8) & 0xff;
+ if (data !== null) frame.set(data, offset);
+ return this.#crcFrame(frame);
+ }
+
+
+ async #sendFrame(writer, frame) {
+ // this.logger.log('this.#sendFrame, size=' + frame.length);
+ await writer.write(frame);
+ }
+
+
+ #sendAttrReadRequest(writer, attribute_id) {
+ this.#sendFrame(writer, this.#makeRequestPacketFrame(this.#transaction_id++, NeoDK.#OPCode.ReadRequest, attribute_id, null));
+ }
+
+
+ #sendAttrWriteRequest(writer, attribute_id, data) {
+ this.#sendFrame(writer, this.#makeRequestPacketFrame(this.#transaction_id++, NeoDK.#OPCode.WriteRequest, attribute_id, data));
+ }
+
+
+ #sendAttrSubscribeRequest(writer, attribute_id) {
+ this.#sendFrame(writer, this.#makeRequestPacketFrame(this.#transaction_id++, NeoDK.#OPCode.SubscribeRequest, attribute_id, null));
+ }
+
+ #handleIncomingDebugPacket(chunk) {
+ if (typeof (this.logger.debug) == 'function') {
+ this.logger.debug(new TextDecoder().decode(chunk));
+ }
+ }
+
+
+ #unpackPatternNames(enc_names) {
+ const decoder = new TextDecoder();
+ let pos = 0;
+ var patternNames = [];
+ while (pos < enc_names.length && enc_names[pos] == NeoDK.#Encoding.UTF8_1Len) {
+ pos += 1;
+ const len = enc_names[pos++];
+ let name = decoder.decode(enc_names.slice(pos, pos + len));
+ patternNames.push(name);
+ this.logger.log(' ' + name);
+ pos += len;
+ }
+ this.state.AvailablePatterns = patternNames;
+ return pos;
+ }
+
+
+ #handleReportedData(aa) {
+ const attribute_id = aa[4] | (aa[5] << 8);
+ let offset = NeoDK.#StructureSize.AttributeAction;
+ const data_length = aa.length - offset;
+ switch (attribute_id) {
+ case NeoDK.#AttributeId.AllPatternNames:
+ if (data_length >= 2 && aa[offset] == NeoDK.#Encoding.Array) {
+ this.logger.log('Available patterns:');
+ offset += this.#unpackPatternNames(aa.slice(offset + 1));
+ }
+ break;
+ case NeoDK.#AttributeId.CurrentPatternName:
+ if (aa[offset] == NeoDK.#Encoding.UTF8_1Len) {
+ const name = new TextDecoder().decode(aa.slice(offset + 2));
+ this.state.CurrentPattern = name;
+ this.logger.log('Current pattern is ' + name);
+ }
+ break;
+ case NeoDK.#AttributeId.IntensityPercent:
+ if (aa[offset] == NeoDK.#Encoding.UnsignedInt1) {
+ const intensity_perc = aa[offset + 1];
+ this.state.Intensity = intensity_perc;
+ this.logger.log('Intensity is ' + intensity_perc + '%');
+ }
+ break;
+ case NeoDK.#AttributeId.PlayPauseStop:
+ if (aa[offset] == NeoDK.#Encoding.UnsignedInt1) {
+ const play_state = aa[offset + 1];
+ if (play_state >= 4) play_state = 0;
+ this.state.PlayState = NeoDK.#playStates[play_state];
+ this.logger.log('NeoDK is ' + NeoDK.#playStates[play_state]);
+ }
+ break;
+ default:
+ this.logger.log('Unexpected attribute id: ' + attribute_id);
+ }
+ }
+
+
+ #handleIncomingDatagram(datagram) {
+ const offset = NeoDK.#StructureSize.PacketHeader;
+ const opcode = datagram[offset + 2];
+ if (opcode == NeoDK.#OPCode.ReportData) {
+ this.#handleReportedData(datagram.slice(offset))
+ } else {
+ const tr_id = datagram[offset] | (datagram[offset + 1] << 8);
+ this.logger.log('Transaction ID=' + tr_id + ', opcode=' + opcode);
+ }
+ }
+
+
+ #assembleIncomingFrame(b) {
+ this.#rx_frame[this.#rx_nb++] = b;
+ // Collect bytes until we have a complete header.
+ if (this.#rx_nb < NeoDK.#StructureSize.FrameHeader) return NeoDK.#FrameType.None;
+
+ // Shift/append the input buffer until it contains a valid header.
+ if (this.#rx_nb == NeoDK.#StructureSize.FrameHeader) {
+ if (this.#rx_frame[5] != (this.#crc8_ccitt(0, this.#rx_frame, 5) & 0xff)) {
+ this.#rx_nb -= 1;
+ for (let i = 0; i < this.#rx_nb; i++) this.#rx_frame[i] = this.#rx_frame[i + 1];
+ return NeoDK.#FrameType.None;
+ }
+ // Valid header received, start collecting the payload (if any).
+ if ((this.#incoming_payload_size = (this.#rx_frame[2] << 8) | this.#rx_frame[3]) > NeoDK.#StructureSize.MaxPayload) {
+ this.logger.log('Frame payload too big: ' + this.#incoming_payload_size + ' bytes');
+ this.#incoming_payload_size = 0;
+ this.#rx_nb = 0;
+ return NeoDK.#FrameType.None;
+ }
+ }
+ if (this.#rx_nb == NeoDK.#StructureSize.FrameHeader + this.#incoming_payload_size) {
+ this.#rx_nb = 0;
+ return (this.#rx_frame[0] >> 1) & 0x7;
+ }
+ return NeoDK.#FrameType.None;
+ }
+
+
+ #processIncomingData(value) {
+ for (let i = 0; i < value.length; i++) {
+ const frame_type = this.#assembleIncomingFrame(value[i]);
+ if (frame_type == NeoDK.#FrameType.None) continue;
+
+ if (frame_type == NeoDK.#FrameType.Ack) {
+ const ack = this.#rx_frame[1] & 0x7;
+ // this.logger.log('Got ACK ' + ack);
+ continue;
+ }
+
+ const service_type = (this.#rx_frame[0] >> 4) & 0x3;
+ if (frame_type == NeoDK.#FrameType.Data) {
+ const seq = (this.#rx_frame[1] >> 3) & 0x7;
+ this.#sendFrame(this.#the_writer, this.#makeAckFrame(service_type, seq));
+ }
+ // this.logger.log('Service type is ' + service_type + ', payload size is ' + this.#incoming_payload_size);
+ if (this.#incoming_payload_size == 0) continue;
+
+ const packet = this.#rx_frame.slice(NeoDK.#StructureSize.FrameHeader, NeoDK.#StructureSize.FrameHeader + this.#incoming_payload_size);
+ if (service_type == NeoDK.#NST.Debug) {
+ this.#handleIncomingDebugPacket(packet);
+ } else if (service_type == NeoDK.#NST.Datagram) {
+ this.#handleIncomingDatagram(packet);
+ }
+ }
+ }
+
+
+ async #readIncomingData(reader) {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) {
+ reader.releaseLock();
+ break;
+ }
+ // this.logger.log('Incoming data length is ' + value.length);
+ this.#processIncomingData(value);
+ }
+ }
+
+
+ async #usePort(port) {
+ await port.open({ baudRate: 115200 }).then(() => {
+ this.logger.log('Opened port ', port.getInfo());
+ this.#the_writer = port.writable.getWriter();
+ this.#sendFrame(this.#the_writer, this.#makeSyncFrame(NeoDK.#NST.Debug));
+
+ this.#readIncomingData(port.readable.getReader());
+
+ // We have one readable attribute and three we can subscribe to.
+ this.#sendAttrReadRequest(this.#the_writer, NeoDK.#AttributeId.AllPatternNames);
+ this.#sendAttrSubscribeRequest(this.#the_writer, NeoDK.#AttributeId.CurrentPatternName);
+ this.#sendAttrSubscribeRequest(this.#the_writer, NeoDK.#AttributeId.IntensityPercent);
+ this.#sendAttrSubscribeRequest(this.#the_writer, NeoDK.#AttributeId.PlayPauseStop);
+ });
+ return true;
+ }
+
+
+ #crc8_ccitt(crc, data, size) {
+ for (let i = 0; i < size; i++) {
+ crc ^= data[i];
+ for (let k = 0; k < 8; k++) {
+ crc = crc & 0x80 ? (crc << 1) ^ 0x07 : crc << 1;
+ }
+ }
+ return crc;
+ }
+
+
+ #crc16_ccitt(crc, data, size) {
+ for (let i = 0; i < size; i++) {
+ crc ^= data[i] << 8;
+ for (let k = 0; k < 8; k++) {
+ crc = crc & 0x8000 ? (crc << 1) ^ 0x1021 : crc << 1;
+ }
+ }
+ return crc;
+ }
+}
+
+export default NeoDK;
\ No newline at end of file
diff --git a/UI/script.js b/UI/script.js
new file mode 100644
index 0000000..62bca46
--- /dev/null
+++ b/UI/script.js
@@ -0,0 +1,95 @@
+import NeoDK from './neodk.js';
+import { createApp, ref, toRaw, computed } from 'vue';
+
+class NeoDKStateVM extends NeoDK.State {
+ constructor() {
+ super();
+ }
+
+ #playState = ref(super._playState);
+ #intensity = ref(super._intensity);
+ #currentPattern = ref(super._currentPattern);
+ #availablePatterns = ref(super._availablePatterns);
+
+ get PlayState() {
+ return toRaw(this).#playState.value;
+ }
+ set PlayState(value) {
+ toRaw(this).#playState.value = value;
+ }
+
+ get Intensity() {
+ return toRaw(this).#intensity.value;
+ }
+ set Intensity(value) {
+ toRaw(this).#intensity.value = value;
+ }
+
+ get CurrentPattern() {
+ return toRaw(this).#currentPattern.value;
+ }
+ set CurrentPattern(value) {
+ toRaw(this).#currentPattern.value = value;
+ }
+
+ get AvailablePatterns() {
+ return toRaw(this).#availablePatterns.value;
+ }
+ set AvailablePatterns(value) {
+ toRaw(this).#availablePatterns.value = value;
+ }
+}
+
+class NeoDKVM extends NeoDK {
+ setIntensity = (intensity) => super.setIntensity(intensity);
+
+ selectPattern = (pattern) => super.selectPattern(pattern);
+
+ changeIntensity = (amount) => { super.setIntensity(this.state.Intensity + amount); }
+
+ resume = () => super.setPlayState(NeoDK.ChangeStateCommand.play);
+ pause = () => super.setPlayState(NeoDK.ChangeStateCommand.pause);
+ stop = () => super.setPlayState(NeoDK.ChangeStateCommand.stop);
+
+ paused = computed(() => ['paused', 'stopped'].includes(this.state.PlayState));
+}
+
+const app = createApp({
+ data() {
+ return {
+ isBrowserSupported: NeoDK.browserSupported,
+ devices: []
+ };
+ },
+ methods: {
+ async connect() {
+ try {
+ var device = new NeoDKVM({ logger: { log: console.log, deubug: console.log }, state: new NeoDKStateVM() });
+ var selected = await device.selectPort();
+ if (selected) {
+ this.devices.push(device);
+ }
+ } catch (error) {
+ alert('Connect failed:' + error);
+ }
+ },
+ async refreshState() {
+ try {
+ this.devices.forEach(element => {
+ // todo add anything that needs to be periodically refreshed here
+ });
+ //setTimeout(this.refreshState, 1000);
+ } catch (error) {
+ console.error('Failed to fetch state', error);
+ }
+ }
+ },
+ mounted() {
+ if (this.isBrowserSupported()) {
+ this.refreshState();
+ }
+ }
+});
+
+app.mount('#app');
+
diff --git a/UI/style.css b/UI/style.css
new file mode 100644
index 0000000..e69de29