21 Revīzijas 4a1d2bee77 ... 4eb988399c

Autors SHA1 Ziņojums Datums
  Scott Bezek 4eb988399c Update README.md 2 gadi atpakaļ
  Grigorii Merkushev 8700af8a24 Fine tuning for esp32s3 (#143) 3 gadi atpakaļ
  Scott Bezek 370b8ca92b BOM - Replace discontinued C2922480 with C15850 3 gadi atpakaļ
  Scott Bezek 6d946c49e5 Handle RequestState messages; update python example 3 gadi atpakaļ
  VIPQualityPost dafc5ca1e2 peg sfoc version (#141) 3 gadi atpakaļ
  Scott Bezek 406dbb08ec Minor README updates 3 gadi atpakaļ
  Scott Bezek 2eddc13528 Add a bunch of proto documentation 3 gadi atpakaļ
  Scott Bezek 590ba8850c Demo improvements (#135) 3 gadi atpakaļ
  Scott Bezek 3cff3533d7 Update README.md 3 gadi atpakaļ
  Scott Bezek 1e2b1983c2 Fix GitHub Actions path filters w/ globs 3 gadi atpakaļ
  Scott Bezek d1495a8334 Strain calibration 3 gadi atpakaļ
  Scott Bezek 1d2458b28a Web Serial library and demo (#125) 3 gadi atpakaļ
  Scott Bezek 948297fa82 Implement persistent configuration/calibration storage using FFAT (#124) 3 gadi atpakaļ
  Scott Bezek 0ebbcc8c23 Revert "SPIFFS persistent storage for motor calibration (#122)" 3 gadi atpakaļ
  Anders Einar Hilden 231b75eca9 Add more info on printed parts to README.md (#123) 3 gadi atpakaļ
  Scott Bezek d64cc55432 Add missing partition file and update GH actions to run nanofoc build 3 gadi atpakaļ
  Scott Bezek 1d73af17cc SPIFFS persistent storage for motor calibration (#122) 3 gadi atpakaļ
  Scott Bezek 2afc38f7f9 Updated config schema, improved demo (#121) 3 gadi atpakaļ
  Scott Bezek 08146e292d Add some basic bounds checking during calibration 3 gadi atpakaļ
  Baku 麦 3fdd9fa601 Use TFT_eSprite.drawNumber for the current position (#118) 3 gadi atpakaļ
  Scott Bezek 9903ac63e6 NanoFOC/ESP32-S3 support (#117) 3 gadi atpakaļ
97 mainītis faili ar 3643 papildinājumiem un 1628 dzēšanām
  1. 10 0
      .github/workflows/electronics.yml
  2. 62 0
      .github/workflows/js.yml
  3. 27 1
      .github/workflows/pio.yml
  4. 4 0
      .vscode/settings.json
  5. 70 15
      README.md
  6. 6 7
      electronics/view_base/view_base.kicad_sch
  7. 11 0
      firmware/partitions-4MB-fat.csv
  8. 140 0
      firmware/src/configuration.cpp
  9. 64 0
      firmware/src/configuration.h
  10. 114 52
      firmware/src/display_task.cpp
  11. 8 0
      firmware/src/interface_callbacks.h
  12. 149 49
      firmware/src/interface_task.cpp
  13. 29 2
      firmware/src/interface_task.h
  14. 11 2
      firmware/src/main.cpp
  15. 13 0
      firmware/src/maq430_sensor.h
  16. 109 59
      firmware/src/motor_task.cpp
  17. 3 1
      firmware/src/motor_task.h
  18. 12 0
      firmware/src/motors/mad2804.h
  19. 9 0
      firmware/src/motors/motor_config.h
  20. 12 0
      firmware/src/motors/wanzhida_once_top.h
  21. 13 2
      firmware/src/mt6701_sensor.cpp
  22. 12 3
      firmware/src/proto_gen/smartknob.pb.c
  23. 248 53
      firmware/src/proto_gen/smartknob.pb.h
  24. 4 0
      firmware/src/serial/proto_helpers.h
  25. 9 4
      firmware/src/serial/serial_protocol_plaintext.cpp
  26. 6 3
      firmware/src/serial/serial_protocol_plaintext.h
  27. 15 4
      firmware/src/serial/serial_protocol_protobuf.cpp
  28. 4 3
      firmware/src/serial/serial_protocol_protobuf.h
  29. 6 0
      firmware/src/util.cpp
  30. 6 0
      firmware/src/util.h
  31. 187 12
      platformio.ini
  32. 187 24
      proto/smartknob.proto
  33. 302 379
      software/js/package-lock.json
  34. 10 7
      software/js/package.json
  35. 0 32
      software/js/packages/demo-backend/package.json
  36. 0 75
      software/js/packages/demo-backend/src/index.ts
  37. 0 43
      software/js/packages/demo-frontend/public/index.html
  38. 0 25
      software/js/packages/demo-frontend/public/manifest.json
  39. 0 407
      software/js/packages/demo-frontend/src/App.tsx
  40. 0 15
      software/js/packages/demo-frontend/src/reportWebVitals.ts
  41. 0 26
      software/js/packages/demo-frontend/tsconfig.json
  42. 0 0
      software/js/packages/example-cli/.eslintrc
  43. 0 0
      software/js/packages/example-cli/.prettierrc
  44. 4 3
      software/js/packages/example-cli/package.json
  45. 16 10
      software/js/packages/example-cli/src/index.ts
  46. 0 0
      software/js/packages/example-cli/tsconfig.json
  47. 0 0
      software/js/packages/example-webserial-basic/.eslintrc
  48. 0 0
      software/js/packages/example-webserial-basic/.gitignore
  49. 0 0
      software/js/packages/example-webserial-basic/.prettierrc
  50. 0 0
      software/js/packages/example-webserial-basic/README.md
  51. 5 4
      software/js/packages/example-webserial-basic/package.json
  52. 0 0
      software/js/packages/example-webserial-basic/public/favicon.ico
  53. 15 0
      software/js/packages/example-webserial-basic/public/index.html
  54. 0 0
      software/js/packages/example-webserial-basic/public/robots.txt
  55. 303 0
      software/js/packages/example-webserial-basic/src/App.tsx
  56. 22 0
      software/js/packages/example-webserial-basic/src/index.tsx
  57. 0 0
      software/js/packages/example-webserial-basic/src/react-app-env.d.ts
  58. 0 2
      software/js/packages/example-webserial-basic/src/util.ts
  59. 20 0
      software/js/packages/example-webserial-basic/tsconfig.json
  60. 0 0
      software/js/packages/example-webserial-timeline/.eslintrc
  61. 23 0
      software/js/packages/example-webserial-timeline/.gitignore
  62. 0 0
      software/js/packages/example-webserial-timeline/.prettierrc
  63. 46 0
      software/js/packages/example-webserial-timeline/README.md
  64. 55 0
      software/js/packages/example-webserial-timeline/package.json
  65. BIN
      software/js/packages/example-webserial-timeline/public/favicon.ico
  66. 15 0
      software/js/packages/example-webserial-timeline/public/index.html
  67. 3 0
      software/js/packages/example-webserial-timeline/public/robots.txt
  68. 555 0
      software/js/packages/example-webserial-timeline/src/App.tsx
  69. 1 7
      software/js/packages/example-webserial-timeline/src/index.tsx
  70. 1 0
      software/js/packages/example-webserial-timeline/src/react-app-env.d.ts
  71. 0 0
      software/js/packages/example-webserial-timeline/src/types.tsx
  72. 29 0
      software/js/packages/example-webserial-timeline/src/util.ts
  73. 20 0
      software/js/packages/example-webserial-timeline/tsconfig.json
  74. 0 105
      software/js/packages/example/tsconfig.json
  75. 0 0
      software/js/packages/smartknobjs-core/.eslintrc
  76. 0 0
      software/js/packages/smartknobjs-core/.prettierrc
  77. 26 0
      software/js/packages/smartknobjs-core/package.json
  78. 43 0
      software/js/packages/smartknobjs-core/src/cobs.ts
  79. 62 66
      software/js/packages/smartknobjs-core/src/index.ts
  80. 105 0
      software/js/packages/smartknobjs-core/tsconfig.json
  81. 19 0
      software/js/packages/smartknobjs-node/.eslintrc
  82. 10 0
      software/js/packages/smartknobjs-node/.prettierrc
  83. 5 7
      software/js/packages/smartknobjs-node/package.json
  84. 19 0
      software/js/packages/smartknobjs-node/src/index.ts
  85. 105 0
      software/js/packages/smartknobjs-node/tsconfig.json
  86. 1 1
      software/js/packages/smartknobjs-proto/package.json
  87. 19 0
      software/js/packages/smartknobjs-webserial/.eslintrc
  88. 10 0
      software/js/packages/smartknobjs-webserial/.prettierrc
  89. 27 0
      software/js/packages/smartknobjs-webserial/package.json
  90. 62 0
      software/js/packages/smartknobjs-webserial/src/index.ts
  91. 105 0
      software/js/packages/smartknobjs-webserial/tsconfig.json
  92. 0 4
      software/js/packages/smartknobjs/src/typings/cobs.d.ts
  93. 0 105
      software/js/packages/smartknobjs/tsconfig.json
  94. 1 3
      software/python/proto_gen/nanopb_pb2.py
  95. 0 1
      software/python/proto_gen/smartknob_pb2.py
  96. 15 5
      software/python/simple_example.py
  97. 4 0
      software/python/smartknob_io.py

+ 10 - 0
.github/workflows/electronics.yml

@@ -2,7 +2,17 @@ name: Export Electronics
 
 on:
   push:
+    paths:
+      - 'electronics/**'
+      - 'util/**'
+      - 'scripts/**'
+      - '.github/workflows/electronics.yml'
   pull_request:
+    paths:
+      - 'electronics/**'
+      - 'util/**'
+      - 'scripts/**'
+      - '.github/workflows/electronics.yml'
 
 jobs:
   export-electronics:

+ 62 - 0
.github/workflows/js.yml

@@ -0,0 +1,62 @@
+name: JS
+
+on:
+  push:
+    paths:
+      - 'software/js/**'
+      - 'proto/**'
+      - '.github/workflows/js.yml'
+  pull_request:
+    paths:
+      - 'software/js/**'
+      - 'proto/**'
+      - '.github/workflows/js.yml'
+  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
+        working-directory: software/js
+        run: npm ci
+      - name: Build
+        working-directory: software/js
+        run: PUBLIC_URL="/smartknob" npm run build
+      - name: Setup Pages
+        uses: actions/configure-pages@v3
+      - name: Upload artifact
+        uses: actions/upload-pages-artifact@v1
+        with:
+          path: './software/js/packages/example-webserial-timeline/build'
+
+  # Deployment job
+  deploy:
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    if: github.repository == 'scottbez1/smartknob' && github.ref == 'refs/heads/master'
+    runs-on: ubuntu-latest
+    needs: build
+    steps:
+      - name: Deploy to GitHub Pages
+        id: deployment
+        uses: actions/deploy-pages@v2

+ 27 - 1
.github/workflows/pio.yml

@@ -2,7 +2,19 @@ name: PlatformIO CI
 
 on:
   push:
+    paths:
+      - 'firmware/**'
+      - 'proto/**'
+      - 'thirdparty/nanopb/**'
+      - 'platformio.ini'
+      - '.github/workflows/pio.yml'
   pull_request:
+    paths:
+      - 'firmware/**'
+      - 'proto/**'
+      - 'thirdparty/nanopb/**'
+      - 'platformio.ini'
+      - '.github/workflows/pio.yml'
 
 jobs:
   pio-build:
@@ -40,4 +52,18 @@ jobs:
       if: always() && steps.pio_install.outcome == 'success'
       run: |
         pio run \
-          -e view \
+          -e view
+
+    - name: Build Firmware (nanofoc)
+      # Run regardless of other build step failures, as long as setup steps completed
+      if: always() && steps.pio_install.outcome == 'success'
+      run: |
+        pio run \
+          -e nanofoc
+
+    - name: Build Firmware (brushknight_esp32s3)
+      # Run regardless of other build step failures, as long as setup steps completed
+      if: always() && steps.pio_install.outcome == 'success'
+      run: |
+        pio run \
+          -e brushknight_esp32s3

+ 4 - 0
.vscode/settings.json

@@ -9,4 +9,8 @@
     "editor.formatOnType": false,
     "editor.formatOnSave": true,
     "editor.formatOnSaveMode": "file",
+    "files.associations": {
+        "cstddef": "cpp",
+        "limits": "cpp"
+    },
 }

+ 70 - 15
README.md

@@ -25,14 +25,14 @@ Features:
  - 240x240 round LCD ("GC9A01"), protected by 39.5mm watch glass on rotor
  - BLDC gimbal motor, with a hollow shaft for mechanically & electrically connecting the LCD
  - Powered by ESP32-PICO-V3-02 (Lilygo TMicro32 Plus module)
- - PCB flexure and strain gauges used for press detection (haptic feedback provided via the motor)
+ - PCB flexure and SMD resistors used as strain gauges for press detection (haptic feedback provided via the motor)
  - 8 side-firing RGB LEDs (SK6812-SIDE-A) illuminate ring around the knob
  - USB-C (2.0) connector for 5V power and serial data/programming (CH340)
  - VEML7700 ambient light sensor for automatic backlight & LED intensity adjustment
  - Versatile back plate for mounting - use either 4x screws, or 2x 3M medium Command strips (with cutouts for accessing removal tabs after installation)
  - Front cover snaps on for easy access to the PCB
 
-**Current status:** Not recommended for general use (mechanical and electrical revisions may be needed depending on motor/electronics availability)
+**Current status:** Not recommended for general use, but may be a fun project for an advanced electronics hobbyist.
 
 ### Demo video
 
@@ -51,6 +51,17 @@ Features:
 
 Latest Fusion 360 Model: https://a360.co/3BzkU0n
 
+
+#### DEMO: video editor timeline control
+
+If you edit audio/video, imagine having a jog wheel where you could _feel_ the clip boundaries as you scrolled through the timeline. Then switch to playback speed control, with a wheel that's spring-loaded to return to "paused," with snaps at 1x, 2x, 4x, etc. The SmartKnob can do this; it just needs some software integrations to be written. Check out a demo using a SmartKnob prototype to control a mock video timeline:
+
+<a href="https://www.youtube.com/watch?v=J9192DfZplk">
+    <img src="https://img.youtube.com/vi/J9192DfZplk/maxresdefault.jpg" width="480" />
+</a>
+
+(you can actually try this demo yourself if you've already built a SmartKnob; plug it in over USB and go to https://scottbez1.github.io/smartknob/)
+
 ### Build your own?
 
 While this is a "DIY" open-source project, it is not yet a mature plug-and-play project. If you intend to build your own, note that it requires advanced soldering experience to build - very small-pitch surface-mount soldering is required (reflow or hot air recommended), and assembly is quite time-consuming and delicate. Please go into it with the expectation that you will almost certainly need to be able to troubleshoot some hardware and firmware issues yourself - I recommend reviewing/understanding the schematics and basic firmware before jumping in!
@@ -61,24 +72,28 @@ View the latest auto-generated (untested) [Base PCB Interactive BOM](https://sma
 
 A few miscellaneous notes in the meantime:
 
- - This can _probably_ be FDM 3D printed with a well-tuned printer, but the parts shown in videos/photos were MJF printed in nylon for tight tolerances and better surface finish
- - If you wanted a simpler build, you could omit the LCD and just merge the knob + glass from the model into a single STL to get a closed-top knob
  - There's limited space inside the LCD mount for wiring, and 8 wires need to fit through the hole in the center. I used 30 AWG wire-wrapping wire. Enamel-coated wire would probably work too.
- - Strain gauges are BF350-3AA, and glued in place with CA glue (I'll include video of this process in the future, but essentially I used kapton tape to pick up the strain gauge and hold it in place during curing). This has to be done after reflow soldering, and would be hard to remove/fix in case of a mistake, so MAKE SURE TO PRACTICE GLUING strain gauges to other items before attempting on the PCB!
+ - Older versions of the design (as seen in the videos) used glued-on BF350-3AA strain gauges to detect presses; in v0.5 these were replaced with footprints for SMD resistors, which exhibit similar properties when stretched/compressed and are much easier to assemble. 
  - The TMC6300 is _tiny_ and has a bottom pad, so I would seriously consider getting a stencil along with the PCB order. Even with the stencil I needed to manually clean up some bridging afterward; I _highly_ recommend Chip Quik NC191 gel flux, available on [Amazon](https://amzn.to/3MGDSr5) (or use this [non-affiliate link](https://www.amazon.com/Smooth-Flow-No-Clean-syringe-plunger/dp/B08KJPG3NZ) instead) or from your electronics distributor of choice. Flux is also very helpful when soldering the LCD ribbon cable to to screen PCB.
- - For breadboard prototyping, the [TMC6300-BOB](https://www.trinamic.com/support/eval-kits/details/tmc6300-bob/) is awesome and way easier to work with than the bare chip if you just want to play around with low current BLDC motors
+ - For breadboard prototyping, the [TMC6300-BOB](https://www.trinamic.com/support/eval-kits/details/tmc6300-bob/) or SparkFun's [TMC6300 driver board](https://www.sparkfun.com/products/21867) are awesome and way easier to work with than the bare chip if you just want to play around with low current BLDC motors
  - For AliExpress purchases: I highly recommend **only** using AliExpress Standard Shipping (purchasing in the US). I have had multiple purchases take months or never get delivered when purchased with Cainiao or other low cost shipping options, whereas AliExpress Standard is very reliable and generally faster in my experience.
- - Make sure to check the [open issues](https://github.com/scottbez1/smartknob/issues) - this design is not yet "stable", so beware that everything may not go smoothly. I would not recommend ordering these parts yourself until the [stable release v1.0 milestone](https://github.com/scottbez1/smartknob/milestone/1) is complete, as there are some mechanical interference issues in the current revision.
+ - Make sure to check the [open issues](https://github.com/scottbez1/smartknob/issues) - this design is not yet "stable", so beware that everything may not go smoothly.
 
 Future plans:
- - consider switch to using an ESP32-S3-MINI-1 module (once Arduino core support is complete), as that would allow for direct USB HID support (for joystick/macro-pad type input to a computer)
- - Bluetooth HID support?
+ - consider switch to using an ESP32-S3-MINI-1 module
  - get wifi configured and working (probably MQTT?). Currently memory is an issue with the full display framebuffer sprite. PSRAM might fix this (requires newer ESP-IDF & unreleased Arduino core, and from a brief test I got horrible performance with PSRAM enabled), or the next item might help reduce memory:
  - migrate to LVGL, for better display rendering and easy support for menus, etc. Shouldn't require a full 240x240x24b framebuffer in memory, freeing some for wifi, etc.
  - Home Assistant integration, or other real-world applications
  - ???
  - [Profit](https://github.com/sponsors/scottbez1/) 😉
 
+#### Already built one?
+Check out the [firmware/software documentation](https://paper.dropbox.com/doc/SmartKnob-firmware-and-software--B_oWj~L1dXqHgAqqYmhwwuqzAg-VUb9nq7btuhnHw5KlgJIH)
+for tips on getting it programmed and hooking it up to your own software integrations.
+
+If you've already uploaded the standard firmware and gone through the calibration process (see guide linked above if you don't know what this means),
+go to https://scottbez1.github.io/smartknob/ to try out an interactive web-based demo that uses Web Serial to talk to a SmartKnob that's plugged into your
+computer's USB port!
 
 #### Base PCB
 
@@ -118,7 +133,7 @@ Latest auto-generated (untested and likely broken!) artifacts⚠️:
     <img src="https://smartknob-artifacts.s3.us-west-1.amazonaws.com/master/electronics/view_screen-back-3d.png" width="300" />
 </a>
 
-Ordering notes: Must be 1.2mm thick (not "standard" 1.6mm) per mechanical design.
+Ordering notes: Must be 1.2mm thick (not "standard" 1.6mm) per mechanical design. (The PCB silkscreen mentions 0.6mm thickness - this is the thickness of the **VHB tape** for adhering the LCD; the PCB should be 1.2mm thick)
 
 There are few enough components on the Screen PCB that I chose to hand-solder them rather than reflow with solder paste
 and a stencil, but if you order a stencil, see the note above about selecting a "Customized size" to be easier to
@@ -137,22 +152,62 @@ Latest auto-generated (untested and likely broken!) artifacts⚠️:
 
 ⚠️ For tested/stable/recommended artifacts, use a [release](https://github.com/scottbez1/smartknob/releases) instead.
 
+#### Printed parts
+You will need six printed parts. The current stable designs can be found as STL assets in the [v185 mechanical release](https://github.com/scottbez1/smartknob/releases/tag/releases%2Fmechanical%2Fv185-dummy-tag) or you can export the latest untested designs from the CAD model by opening it in Fusion 360: https://a360.co/3BzkU0n
+
+The parts can _probably_ be FDM 3D printed with a well-tuned printer, but the parts shown in videos/photos were MJF printed in nylon for tight tolerances and better surface finish. 
+
+If you wanted a simpler build, you could omit the LCD and just merge the knob + glass from the model into a single STL to get a closed-top knob.
 
-## SmartKnob Mini
-Planned for the future.
+Export and print the following 6 parts:
+* `Enclosure`
+* `Knob` (RotorAssembly->KnobAssembly->Knob)
+* `ScreenPlatform`
+* `RotorSpacer` (RotorAssembly->RotorSpacer)
+* `MountBase`
+* `BackPlate`
 
+⚠️ If you opt to go for MJF Nylon printing from JLCPCB, their system may complain about `< 0.8mm wall thickness detected` on the `MountBase`, `ScreenPlatform` and `RotorSpacer` parts. This appears to be a false positive, possibly on some of the print tapers. You should be able to ignore this, as all print orders at JLCPCB are subject to a final manual review, and they will contact you by email if there are any job-stopping issues or risks with your prints.
+
+## NanoFOC (3rd party)
+If you're looking to tinker with FOC/haptic feedback, but don't want to build a full SmartKnob View yourself, I can
+recommend the NanoFOC DevKit++, an [open-source design](https://github.com/katbinaris/nanofoc_devkit) made and
+[sold](https://store.binaris.io/products/nanofoc-devkit) by a member of the SmartKnob community! It's super compact
+and is a great testbed or core for building your own BLDC-based haptic input device.
+
+![Image of the NanoFOC PCB](https://cdn.shopify.com/s/files/1/0729/7433/6335/products/IMG_20230418_120554-01.jpg?width=416)
+
+
+The NanoFOC uses an ESP32-S3, and the SmartKnob firmware works on it out of the box; just select the `nanofoc`
+environment in PlatformIO rather than the `view` environment when uploading.
 
 # Frequently Asked Questions (FAQ)
 
-**How much does it cost?**
+### Can I use this cheap gimbal BLDC motor I found on AliExpress?
+
+I can't stop you, but I will caution that **you probably won't be super happy with it** depending on your intended application.
+
+Nearly every off-the-shelf BLDC gimbal motor that the community has tested (which is pretty much ALL the cheap ones you'll find on AliExpress, trust me) has
+_moderate to severge cogging_. That means the motor wants to snap to certain positions by itself even when unpowered. This means you won't be able to get
+completely smooth rotation when virtual detents are turned off, and the motor's cogging may interfere with or overpower the virtual detents, particularly if
+you set them to lower strength or make then very fine-grained.
+
+The recommended motor is definitely the best (i.e. lowest cogging) off-the-shelf motor we've come across so far, and is the only readily-available motor
+that I would consider "good" for this application.
+
+If you've found another motor that is completely smooth when unpowered, we'd LOVE to hear about it in the discord; if you're wondering about some cheap motor
+you saw online, either just order one and try it for yourself, or ask about it in discord but don't be surprised (or offended) if the response you get is
+something terse like "nope, no good".
+
+### How much does it cost?
 
 I wish I could tell you now, but I don't actually know off the top of my head. Check back soon - I've only built a few so far, which was the result of a bunch of tinkering and prototyping over an extended period, so I don't have all the expenses tallied up yet. Probably less than $200 in parts? But some items have gotten more expensive, and you may be limited by minimum order quantities or shipping charges from multiple separate suppliers.
 
-**Does it work with XYZ?**
+### Does it work with XYZ?
 
 Not yet, regardless of whatever "XYZ" you're thinking of. So far I've only implemented enough firmware for the demo shown in the video, so you can't actually use it for anything productive yet. The basic detent configuration API is there, but not much else. Lots of firmware work remains to be done. If you build one, I'd love your help adding support for "XYZ" though!
 
-**Can I buy one as a kit or already assembled?**
+### Can I buy one as a kit or already assembled?
 
 Probably not? Or at least, I don't have any immediate plans to sell them myself. It's not that I don't want you to be happy, but hardware is a hard business and I just work on this stuff in my free time.
 

+ 6 - 7
electronics/view_base/view_base.kicad_sch

@@ -2122,7 +2122,6 @@
     )
   )
 
-
   (junction (at 200.66 250.19) (diameter 0) (color 0 0 0 0)
     (uuid 00e38d63-5436-49db-81f5-697421f168fc)
   )
@@ -5608,7 +5607,7 @@
     (property "Datasheet" "~" (id 3) (at 158.75 184.15 0)
       (effects (font (size 1.27 1.27)) hide)
     )
-    (property "LCSC" "C2922480" (id 4) (at 158.75 184.15 0)
+    (property "LCSC" "C15850" (id 4) (at 158.75 184.15 0)
       (effects (font (size 1.27 1.27)) hide)
     )
     (property "Digikey" "1276-2891-1-ND" (id 5) (at 158.75 184.15 0)
@@ -5778,7 +5777,7 @@
     (property "Datasheet" "~" (id 3) (at 22.86 33.02 0)
       (effects (font (size 1.27 1.27)) hide)
     )
-    (property "LCSC" "C2922480" (id 4) (at 22.86 33.02 0)
+    (property "LCSC" "C15850" (id 4) (at 22.86 33.02 0)
       (effects (font (size 1.27 1.27)) hide)
     )
     (property "Digikey" "1276-2891-1-ND" (id 5) (at 22.86 33.02 0)
@@ -5806,7 +5805,7 @@
     (property "Datasheet" "~" (id 3) (at 36.83 33.02 0)
       (effects (font (size 1.27 1.27)) hide)
     )
-    (property "LCSC" "C2922480" (id 4) (at 36.83 33.02 0)
+    (property "LCSC" "C15850" (id 4) (at 36.83 33.02 0)
       (effects (font (size 1.27 1.27)) hide)
     )
     (property "Digikey" "1276-2891-1-ND" (id 5) (at 36.83 33.02 0)
@@ -5866,7 +5865,7 @@
     (property "Datasheet" "~" (id 3) (at 172.72 165.1 0)
       (effects (font (size 1.27 1.27)) hide)
     )
-    (property "LCSC" "C2922480" (id 4) (at 172.72 165.1 0)
+    (property "LCSC" "C15850" (id 4) (at 172.72 165.1 0)
       (effects (font (size 1.27 1.27)) hide)
     )
     (property "Digikey" "1276-2891-1-ND" (id 5) (at 172.72 165.1 0)
@@ -7422,7 +7421,7 @@
     (property "Datasheet" "~" (id 3) (at 317.5 30.48 0)
       (effects (font (size 1.27 1.27)) hide)
     )
-    (property "LCSC" "C2922480" (id 4) (at 317.5 30.48 0)
+    (property "LCSC" "C15850" (id 4) (at 317.5 30.48 0)
       (effects (font (size 1.27 1.27)) hide)
     )
     (property "Digikey" "1276-2891-1-ND" (id 5) (at 317.5 30.48 0)
@@ -7610,7 +7609,7 @@
     (property "Datasheet" "~" (id 3) (at 224.79 73.66 0)
       (effects (font (size 1.27 1.27)) hide)
     )
-    (property "LCSC" "C2922480" (id 4) (at 224.79 73.66 0)
+    (property "LCSC" "C15850" (id 4) (at 224.79 73.66 0)
       (effects (font (size 1.27 1.27)) hide)
     )
     (property "Digikey" "1276-2891-1-ND" (id 5) (at 224.79 73.66 0)

+ 11 - 0
firmware/partitions-4MB-fat.csv

@@ -0,0 +1,11 @@
+# ESP-IDF Partition Table
+# Name,   Type, SubType, Offset,  Size, Flags
+# bootloader.bin,,          0x1000, 32K
+# partition table,          0x8000, 4K
+
+nvs,      data, nvs,      0x9000,  20K,
+otadata,  data, ota,      0xe000,  8K,
+ota_0,    0,    ota_0,   0x10000,  1408K,
+ota_1,    0,    ota_1,  0x170000,  1408K,
+uf2,      app,  factory,0x2d0000,  256K,
+ffat,     data, fat,    0x310000,  960K,

+ 140 - 0
firmware/src/configuration.cpp

@@ -0,0 +1,140 @@
+#include <FFat.h>
+
+#include "pb_decode.h"
+#include "pb_encode.h"
+
+#include "proto_gen/smartknob.pb.h"
+#include "semaphore_guard.h"
+
+#include "configuration.h"
+
+static const char* CONFIG_PATH = "/config.pb";
+
+Configuration::Configuration() {
+  mutex_ = xSemaphoreCreateMutex();
+  assert(mutex_ != NULL);
+}
+
+Configuration::~Configuration() {
+  vSemaphoreDelete(mutex_);
+}
+
+bool Configuration::loadFromDisk() {
+    SemaphoreGuard lock(mutex_);
+    FatGuard fatGuard(logger_);
+    if (!fatGuard.mounted_) {
+        return false;
+    }
+
+    File f = FFat.open(CONFIG_PATH);
+    if (!f) {
+        log("Failed to read config file");
+        return false;
+    }
+
+    size_t read = f.readBytes((char*)buffer_, sizeof(buffer_));
+    f.close();
+
+    pb_istream_t stream = pb_istream_from_buffer(buffer_, read);
+    if (!pb_decode(&stream, PB_PersistentConfiguration_fields, &pb_buffer_)) {
+        char buf[200];
+        snprintf(buf, sizeof(buf), "Decoding failed: %s", PB_GET_ERROR(&stream));
+        log(buf);
+        pb_buffer_ = {};
+        return false;
+    }
+
+    if (pb_buffer_.version != PERSISTENT_CONFIGURATION_VERSION) {
+        char buf[200];
+        snprintf(buf, sizeof(buf), "Invalid config version. Expected %u, received %u", PERSISTENT_CONFIGURATION_VERSION, pb_buffer_.version);
+        log(buf);
+        pb_buffer_ = {};
+        return false;
+    }
+    loaded_ = true;
+
+    char buf[200];
+    snprintf(
+        buf,
+        sizeof(buf),
+        "Motor calibration: calib=%u, pole_pairs=%u, zero_offset=%.2f, cw=%u",
+        pb_buffer_.motor.calibrated,
+        pb_buffer_.motor.pole_pairs,
+        pb_buffer_.motor.zero_electrical_offset,
+        pb_buffer_.motor.direction_cw
+    );
+    log(buf);
+    return true;
+}
+
+bool Configuration::saveToDisk() {
+    SemaphoreGuard lock(mutex_);
+
+    pb_ostream_t stream = pb_ostream_from_buffer(buffer_, sizeof(buffer_));
+    pb_buffer_.version = PERSISTENT_CONFIGURATION_VERSION;
+    if (!pb_encode(&stream, PB_PersistentConfiguration_fields, &pb_buffer_)) {
+        char buf[200];
+        snprintf(buf, sizeof(buf), "Encoding failed: %s", PB_GET_ERROR(&stream));
+        log(buf);
+        return false;
+    }
+
+    FatGuard fatGuard(logger_);
+    if (!fatGuard.mounted_) {
+        return false;
+    }
+    File f = FFat.open(CONFIG_PATH, FILE_WRITE);
+    if (!f) {
+        log("Failed to read config file");
+        return false;
+    }
+    size_t written = f.write(buffer_, stream.bytes_written);
+    f.close();
+
+    char buf[20];
+    snprintf(buf, sizeof(buf), "Wrote %d bytes", written);
+    log(buf);
+
+    if (written != stream.bytes_written) {
+        log("Failed to write all bytes to file");
+        return false;
+    }
+
+    return true;
+}
+
+PB_PersistentConfiguration Configuration::get() {
+    SemaphoreGuard lock(mutex_);
+    if (!loaded_) {
+        return PB_PersistentConfiguration();
+    }
+    return pb_buffer_;
+}
+
+bool Configuration::setMotorCalibrationAndSave(PB_MotorCalibration& motor_calibration) {
+    {
+        SemaphoreGuard lock(mutex_);
+        pb_buffer_.motor = motor_calibration;
+        pb_buffer_.has_motor = true;
+    }
+    return saveToDisk();
+}
+
+bool Configuration::setStrainCalibrationAndSave(PB_StrainCalibration& strain_calibration) {
+    {
+        SemaphoreGuard lock(mutex_);
+        pb_buffer_.strain = strain_calibration;
+        pb_buffer_.has_strain = true;
+    }
+    return saveToDisk();
+}
+
+void Configuration::setLogger(Logger* logger) {
+    logger_ = logger;
+}
+
+void Configuration::log(const char* msg) {
+    if (logger_ != nullptr) {
+        logger_->log(msg);
+    }
+}

+ 64 - 0
firmware/src/configuration.h

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <FFat.h>
+#include <PacketSerial.h>
+
+#include "proto_gen/smartknob.pb.h"
+
+#include "logger.h"
+
+const uint32_t PERSISTENT_CONFIGURATION_VERSION = 1;
+
+class Configuration {
+    public:
+        Configuration();
+        ~Configuration();
+
+        void setLogger(Logger* logger);
+        bool loadFromDisk();
+        bool saveToDisk();
+        PB_PersistentConfiguration get();
+        bool setMotorCalibrationAndSave(PB_MotorCalibration& motor_calibration);
+        bool setStrainCalibrationAndSave(PB_StrainCalibration& strain_calibration);
+
+    private:
+        SemaphoreHandle_t mutex_;
+
+        Logger* logger_ = nullptr;
+        bool loaded_ = false;
+        PB_PersistentConfiguration pb_buffer_ = {};
+
+        uint8_t buffer_[PB_PersistentConfiguration_size];
+
+        void log(const char* msg);
+};
+class FatGuard {
+    public:
+        FatGuard(Logger* logger) : logger_(logger) {
+            if (!FFat.begin(true)) {
+                if (logger_ != nullptr) {
+                    logger_->log("Failed to mount FFat");
+                }
+                return;
+            }
+            if (logger_ != nullptr) {
+                logger_->log("Mounted FFat");
+            }
+            mounted_ = true;
+        }
+        ~FatGuard() {
+            if (mounted_) {
+                FFat.end();
+                if (logger_ != nullptr) {
+                    logger_->log("Unmounted FFat");
+                }
+            }
+        }
+        FatGuard(FatGuard const&)=delete;
+        FatGuard& operator=(FatGuard const&)=delete;
+
+        bool mounted_ = false;
+
+    private:
+        Logger* logger_;
+};

+ 114 - 52
firmware/src/display_task.cpp

@@ -1,6 +1,7 @@
 #if SK_DISPLAY
 #include "display_task.h"
 #include "semaphore_guard.h"
+#include "util.h"
 
 #include "font/roboto_light_60.h"
 
@@ -19,15 +20,24 @@ DisplayTask::~DisplayTask() {
   vSemaphoreDelete(mutex_);
 }
 
+static void drawPlayButton(TFT_eSprite& spr, int x, int y, int width, int height, uint16_t color) {
+  spr.fillTriangle(
+    x, y - height / 2,
+    x, y + height / 2,
+    x + width, y,
+    color
+  );
+}
+
 void DisplayTask::run() {
     tft_.begin();
     tft_.invertDisplay(1);
     tft_.setRotation(SK_DISPLAY_ROTATION);
     tft_.fillScreen(TFT_DARKGREEN);
 
-    ledcSetup(LEDC_CHANNEL_LCD_BACKLIGHT, 5000, 16);
+    ledcSetup(LEDC_CHANNEL_LCD_BACKLIGHT, 5000, SK_BACKLIGHT_BIT_DEPTH);
     ledcAttachPin(PIN_LCD_BACKLIGHT, LEDC_CHANNEL_LCD_BACKLIGHT);
-    ledcWrite(LEDC_CHANNEL_LCD_BACKLIGHT, UINT16_MAX);
+    ledcWrite(LEDC_CHANNEL_LCD_BACKLIGHT, (1 << SK_BACKLIGHT_BIT_DEPTH) - 1);
 
     spr_.setColorDepth(8);
 
@@ -56,43 +66,6 @@ void DisplayTask::run() {
         spr_.fillSprite(TFT_BLACK);
 
         int32_t num_positions = state.config.max_position - state.config.min_position + 1;
-        if (num_positions > 1) {
-          int32_t height = (state.current_position - state.config.min_position) * TFT_HEIGHT / (state.config.max_position - state.config.min_position);
-          spr_.fillRect(0, TFT_HEIGHT - height, TFT_WIDTH, height, FILL_COLOR);
-        }
-
-        spr_.setFreeFont(&Roboto_Light_60);
-        spr_.drawString(String() + state.current_position, TFT_WIDTH / 2, TFT_HEIGHT / 2 - VALUE_OFFSET, 1);
-        spr_.setFreeFont(&DESCRIPTION_FONT);
-        int32_t line_y = TFT_HEIGHT / 2 + DESCRIPTION_Y_OFFSET;
-        char* start = state.config.text;
-        char* end = start + strlen(state.config.text);
-        while (start < end) {
-          char* newline = strchr(start, '\n');
-          if (newline == nullptr) {
-            newline = end;
-          }
-          
-          char buf[sizeof(state.config.text)] = {};
-          strncat(buf, start, min(sizeof(buf) - 1, (size_t)(newline - start)));
-          spr_.drawString(String(buf), TFT_WIDTH / 2, line_y, 1);
-          start = newline + 1;
-          line_y += spr_.fontHeight(1);
-        }
-
-        float left_bound = PI / 2;
-
-        if (num_positions > 0) {
-          float range_radians = (state.config.max_position - state.config.min_position) * state.config.position_width_radians;
-          left_bound = PI / 2 + range_radians / 2;
-          float right_bound = PI / 2 - range_radians / 2;
-          spr_.drawLine(TFT_WIDTH/2 + RADIUS * cosf(left_bound), TFT_HEIGHT/2 - RADIUS * sinf(left_bound), TFT_WIDTH/2 + (RADIUS - 10) * cosf(left_bound), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(left_bound), TFT_WHITE);
-          spr_.drawLine(TFT_WIDTH/2 + RADIUS * cosf(right_bound), TFT_HEIGHT/2 - RADIUS * sinf(right_bound), TFT_WIDTH/2 + (RADIUS - 10) * cosf(right_bound), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(right_bound), TFT_WHITE);
-        }
-        if (DRAW_ARC) {
-          spr_.drawCircle(TFT_WIDTH/2, TFT_HEIGHT/2, RADIUS, TFT_DARKGREY);
-        }
-
         float adjusted_sub_position = state.sub_position_unit * state.config.position_width_radians;
         if (num_positions > 0) {
           if (state.current_position == state.config.min_position && state.sub_position_unit < 0) {
@@ -102,25 +75,114 @@ void DisplayTask::run() {
           }
         }
 
+        float left_bound = PI / 2;
+        float right_bound = 0;
+        if (num_positions > 0) {
+          float range_radians = (state.config.max_position - state.config.min_position) * state.config.position_width_radians;
+          left_bound = PI / 2 + range_radians / 2;
+          right_bound = PI / 2 - range_radians / 2;
+        }
         float raw_angle = left_bound - (state.current_position - state.config.min_position) * state.config.position_width_radians;
         float adjusted_angle = raw_angle - adjusted_sub_position;
+        
+        bool sk_demo_mode = strncmp(state.config.text, "SKDEMO_", 7) == 0;
 
-        if (num_positions > 0 && ((state.current_position == state.config.min_position && state.sub_position_unit < 0) || (state.current_position == state.config.max_position && state.sub_position_unit > 0))) {
+        if (!sk_demo_mode) {
+          if (num_positions > 1) {
+            int32_t height = (state.current_position - state.config.min_position) * TFT_HEIGHT / (state.config.max_position - state.config.min_position);
+            spr_.fillRect(0, TFT_HEIGHT - height, TFT_WIDTH, height, FILL_COLOR);
+          }
 
-          spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(raw_angle), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(raw_angle), 5, DOT_COLOR);
-          if (raw_angle < adjusted_angle) {
-            for (float r = raw_angle; r <= adjusted_angle; r += 2 * PI / 180) {
-              spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(r), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(r), 2, DOT_COLOR);
+          spr_.setFreeFont(&Roboto_Light_60);
+          spr_.drawNumber(state.current_position, TFT_WIDTH / 2, TFT_HEIGHT / 2 - VALUE_OFFSET, 1);
+          spr_.setFreeFont(&DESCRIPTION_FONT);
+          int32_t line_y = TFT_HEIGHT / 2 + DESCRIPTION_Y_OFFSET;
+          char* start = state.config.text;
+          char* end = start + strlen(state.config.text);
+          while (start < end) {
+            char* newline = strchr(start, '\n');
+            if (newline == nullptr) {
+              newline = end;
             }
-            spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(adjusted_angle), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(adjusted_angle), 2, DOT_COLOR);
-          } else {
-            for (float r = raw_angle; r >= adjusted_angle; r -= 2 * PI / 180) {
-              spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(r), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(r), 2, DOT_COLOR);
+            
+            char buf[sizeof(state.config.text)] = {};
+            strncat(buf, start, min(sizeof(buf) - 1, (size_t)(newline - start)));
+            spr_.drawString(String(buf), TFT_WIDTH / 2, line_y, 1);
+            start = newline + 1;
+            line_y += spr_.fontHeight(1);
+          }
+
+          if (num_positions > 0) {
+            spr_.drawLine(TFT_WIDTH/2 + RADIUS * cosf(left_bound), TFT_HEIGHT/2 - RADIUS * sinf(left_bound), TFT_WIDTH/2 + (RADIUS - 10) * cosf(left_bound), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(left_bound), TFT_WHITE);
+            spr_.drawLine(TFT_WIDTH/2 + RADIUS * cosf(right_bound), TFT_HEIGHT/2 - RADIUS * sinf(right_bound), TFT_WIDTH/2 + (RADIUS - 10) * cosf(right_bound), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(right_bound), TFT_WHITE);
+          }
+          if (DRAW_ARC) {
+            spr_.drawCircle(TFT_WIDTH/2, TFT_HEIGHT/2, RADIUS, TFT_DARKGREY);
+          }
+
+          if (num_positions > 0 && ((state.current_position == state.config.min_position && state.sub_position_unit < 0) || (state.current_position == state.config.max_position && state.sub_position_unit > 0))) {
+            spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(raw_angle), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(raw_angle), 5, DOT_COLOR);
+            if (raw_angle < adjusted_angle) {
+              for (float r = raw_angle; r <= adjusted_angle; r += 2 * PI / 180) {
+                spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(r), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(r), 2, DOT_COLOR);
+              }
+              spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(adjusted_angle), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(adjusted_angle), 2, DOT_COLOR);
+            } else {
+              for (float r = raw_angle; r >= adjusted_angle; r -= 2 * PI / 180) {
+                spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(r), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(r), 2, DOT_COLOR);
+              }
+              spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(adjusted_angle), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(adjusted_angle), 2, DOT_COLOR);
             }
-            spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(adjusted_angle), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(adjusted_angle), 2, DOT_COLOR);
+          } else {
+            spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(adjusted_angle), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(adjusted_angle), 5, DOT_COLOR);
           }
         } else {
-          spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 10) * cosf(adjusted_angle), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(adjusted_angle), 5, DOT_COLOR);
+          if (strncmp(state.config.text, "SKDEMO_Scroll", 13) == 0) {
+            spr_.fillRect(0, 0, TFT_WIDTH, TFT_HEIGHT, spr_.color565(150, 0, 0));
+            spr_.setFreeFont(&Roboto_Thin_24);
+            spr_.drawString("Scroll", TFT_WIDTH / 2, TFT_HEIGHT / 2, 1);
+            bool detent = false;
+            for (uint8_t i = 0; i < state.config.detent_positions_count; i++) {
+              if (state.config.detent_positions[i] == state.current_position) {
+                detent = true;
+                break;
+              }
+            }
+            spr_.fillCircle(TFT_WIDTH/2 + (RADIUS - 16) * cosf(adjusted_angle), TFT_HEIGHT/2 - (RADIUS - 16) * sinf(adjusted_angle), detent ? 8 : 5, TFT_WHITE);
+          } else if (strncmp(state.config.text, "SKDEMO_Frames", 13) == 0) {
+            int32_t width = (state.current_position - state.config.min_position) * TFT_WIDTH / (state.config.max_position - state.config.min_position);
+            spr_.fillRect(0, 0, width, TFT_HEIGHT, spr_.color565(0, 150, 0));
+            spr_.setFreeFont(&Roboto_Light_60);
+            spr_.drawNumber(state.current_position, TFT_WIDTH / 2, TFT_HEIGHT / 2, 1);
+            spr_.setFreeFont(&Roboto_Thin_24);
+            spr_.drawString("Frame", TFT_WIDTH / 2, TFT_HEIGHT / 2 - DESCRIPTION_Y_OFFSET - VALUE_OFFSET, 1);
+          } else if (strncmp(state.config.text, "SKDEMO_Speed", 12) == 0) {
+            spr_.fillRect(0, 0, TFT_WIDTH, TFT_HEIGHT, spr_.color565(0, 0, 150));
+
+            float normalizedFractional = sgn(state.sub_position_unit) *
+                CLAMP(lerp(state.sub_position_unit * sgn(state.sub_position_unit), 0.1, 0.9, 0, 1), (float)0, (float)1);
+            float normalized = state.current_position + normalizedFractional;
+            float speed = sgn(normalized) * powf(2, fabsf(normalized) - 1);
+            float roundedSpeed = truncf(speed * 10) / 10;
+
+            spr_.setFreeFont(&Roboto_Thin_24);
+            if (roundedSpeed == 0) {
+              spr_.drawString("Paused", TFT_WIDTH / 2, TFT_HEIGHT / 2 + DESCRIPTION_Y_OFFSET + VALUE_OFFSET, 1);
+
+              spr_.fillRect(TFT_WIDTH / 2 + 5, TFT_HEIGHT / 2 - 20, 10, 40, TFT_WHITE);
+              spr_.fillRect(TFT_WIDTH / 2 - 5 - 10, TFT_HEIGHT / 2 - 20, 10, 40, TFT_WHITE);
+            } else {
+              char buf[10];
+              snprintf(buf, sizeof(buf), "%0.1fx", roundedSpeed);
+              spr_.drawString(buf, TFT_WIDTH / 2, TFT_HEIGHT / 2 + DESCRIPTION_Y_OFFSET + VALUE_OFFSET, 1);
+
+              uint16_t x = TFT_WIDTH / 2;
+              for (uint8_t i = 0; i < max(1, abs(state.current_position)); i++) {
+                drawPlayButton(spr_, x, TFT_HEIGHT / 2, sgn(roundedSpeed) * 20, 40, TFT_WHITE);
+                x += sgn(roundedSpeed) * 20;
+              }
+            }
+          }
         }
 
         spr_.pushSprite(0, 0);
@@ -129,7 +191,7 @@ void DisplayTask::run() {
           SemaphoreGuard lock(mutex_);
           ledcWrite(LEDC_CHANNEL_LCD_BACKLIGHT, brightness_);
         }
-        delay(2);
+        delay(5);
     }
 }
 
@@ -139,7 +201,7 @@ QueueHandle_t DisplayTask::getKnobStateQueue() {
 
 void DisplayTask::setBrightness(uint16_t brightness) {
   SemaphoreGuard lock(mutex_);
-  brightness_ = brightness;
+  brightness_ = brightness >> (16 - SK_BACKLIGHT_BIT_DEPTH);
 }
 
 void DisplayTask::setLogger(Logger* logger) {

+ 8 - 0
firmware/src/interface_callbacks.h

@@ -0,0 +1,8 @@
+#pragma once
+
+#include <functional>
+
+#include "proto_gen/smartknob.pb.h"
+
+typedef std::function<void(PB_SmartKnobConfig&)> ConfigCallback;
+typedef std::function<void(void)> MotorCalibrationCallback;

+ 149 - 49
firmware/src/interface_task.cpp

@@ -11,6 +11,7 @@
 #endif
 
 #include "interface_task.h"
+#include "semaphore_guard.h"
 #include "util.h"
 
 #if SK_LEDS
@@ -27,6 +28,8 @@ Adafruit_VEML7700 veml = Adafruit_VEML7700();
 
 static PB_SmartKnobConfig configs[] = {
     // int32_t position;
+    // float sub_position_unit;
+    // uint8_t position_nonce;
     // int32_t min_position;
     // int32_t max_position;
     // float position_width_radians;
@@ -37,8 +40,11 @@ static PB_SmartKnobConfig configs[] = {
     // pb_size_t detent_positions_count;
     // int32_t detent_positions[5];
     // float snap_point_bias;
+    // int8_t led_hue;
 
     {
+        0,
+        0,
         0,
         0,
         -1, // max position < min position indicates no bounds
@@ -50,10 +56,13 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
+        200,
     },
     {
         0,
         0,
+        1,
+        0,
         10,
         10 * PI / 180,
         0,
@@ -63,10 +72,13 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
+        0,
     },
     {
         0,
         0,
+        2,
+        0,
         72,
         10 * PI / 180,
         0,
@@ -76,10 +88,13 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
+        73,
     },
     {
         0,
         0,
+        3,
+        0,
         1,
         60 * PI / 180,
         1,
@@ -89,10 +104,13 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
+        157,
     },
     {
         0,
         0,
+        4,
+        0,
         0,
         60 * PI / 180,
         0.01,
@@ -102,10 +120,13 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
+        45,
     },
     {
         127,
         0,
+        5,
+        0,
         255,
         1 * PI / 180,
         0,
@@ -115,10 +136,13 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
+        219,
     },
     {
         127,
         0,
+        5,
+        0,
         255,
         1 * PI / 180,
         1,
@@ -128,10 +152,13 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
+        25,
     },
     {
         0,
         0,
+        6,
+        0,
         31,
         8.225806452 * PI / 180,
         2,
@@ -141,10 +168,13 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
+        200,
     },
     {
         0,
         0,
+        6,
+        0,
         31,
         8.225806452 * PI / 180,
         0.2,
@@ -154,10 +184,13 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
+        0,
     },
     {
         0,
         0,
+        7,
+        0,
         31,
         7 * PI / 180,
         2.5,
@@ -167,9 +200,12 @@ static PB_SmartKnobConfig configs[] = {
         4,
         {2, 10, 21, 22},
         0,
+        73,
     },
     {
         0,
+        0,
+        8,
         -6,
         6,
         60 * PI / 180,
@@ -179,26 +215,39 @@ static PB_SmartKnobConfig configs[] = {
         "Return-to-center\nwith detents",
         0,
         {},
-        0.4
+        0.4,
+        157,
     },
 };
 
 InterfaceTask::InterfaceTask(const uint8_t task_core, MotorTask& motor_task, DisplayTask* display_task) : 
-        Task("Interface", 3000, 1, task_core),
+        Task("Interface", 3400, 1, task_core),
         stream_(),
         motor_task_(motor_task),
         display_task_(display_task),
-        plaintext_protocol_(stream_, motor_task_),
-        proto_protocol_(stream_, motor_task_) {
+        plaintext_protocol_(stream_, [this] () {
+            motor_task_.runCalibration();
+        }),
+        proto_protocol_(stream_, [this] (PB_SmartKnobConfig& config) {
+            applyConfig(config, true);
+        }) {
     #if SK_DISPLAY
         assert(display_task != nullptr);
     #endif
 
+
     log_queue_ = xQueueCreate(10, sizeof(std::string *));
     assert(log_queue_ != NULL);
 
     knob_state_queue_ = xQueueCreate(1, sizeof(PB_SmartKnobState));
     assert(knob_state_queue_ != NULL);
+
+    mutex_ = xSemaphoreCreateMutex();
+    assert(mutex_ != NULL);
+}
+
+InterfaceTask::~InterfaceTask() {
+    vSemaphoreDelete(mutex_);
 }
 
 void InterfaceTask::run() {
@@ -213,7 +262,7 @@ void InterfaceTask::run() {
         Wire.setClock(400000);
     #endif
     #if SK_STRAIN
-        scale.begin(38, 2);
+        scale.begin(PIN_STRAIN_DO, PIN_STRAIN_SCK);
     #endif
 
     #if SK_ALS
@@ -225,23 +274,49 @@ void InterfaceTask::run() {
         }
     #endif
 
-    motor_task_.setConfig(configs[0]);
+    applyConfig(configs[0], false);
     motor_task_.addListener(knob_state_queue_);
 
-
-    // Start in legacy protocol mode
     plaintext_protocol_.init([this] () {
         changeConfig(true);
+    }, [this] () {
+        if (!configuration_loaded_) {
+            return;
+        }
+        if (strain_calibration_step_ == 0) {
+            log("Strain calibration step 1: Don't touch the knob, then press 'S' again");
+            strain_calibration_step_ = 1;
+        } else if (strain_calibration_step_ == 1) {
+            configuration_value_.strain.idle_value = strain_reading_;
+            snprintf(buf_, sizeof(buf_), "  idle_value=%d", configuration_value_.strain.idle_value);
+            log(buf_);
+            log("Strain calibration step 2: Push and hold down the knob with medium pressure, and press 'S' again");
+            strain_calibration_step_ = 2;
+        } else if (strain_calibration_step_ == 2) {
+            configuration_value_.strain.press_delta = strain_reading_ - configuration_value_.strain.idle_value;
+            configuration_value_.has_strain = true;
+            snprintf(buf_, sizeof(buf_), "  press_delta=%d", configuration_value_.strain.press_delta);
+            log(buf_);
+            log("Strain calibration complete! Saving...");
+            strain_calibration_step_ = 0;
+            if (configuration_->setStrainCalibrationAndSave(configuration_value_.strain)) {
+                log("  Saved!");
+            } else {
+                log("  FAILED to save config!!!");
+            }
+        }
     });
-    SerialProtocol* current_protocol = &plaintext_protocol_;
 
-    ProtocolChangeCallback protocol_change_callback = [this, &current_protocol] (uint8_t protocol) {
+    // Start in legacy protocol mode
+    current_protocol_ = &plaintext_protocol_;
+
+    ProtocolChangeCallback protocol_change_callback = [this] (uint8_t protocol) {
         switch (protocol) {
             case SERIAL_PROTOCOL_LEGACY:
-                current_protocol = &plaintext_protocol_;
+                current_protocol_ = &plaintext_protocol_;
                 break;
             case SERIAL_PROTOCOL_PROTO:
-                current_protocol = &proto_protocol_;
+                current_protocol_ = &proto_protocol_;
                 break;
             default:
                 log("Unknown protocol requested");
@@ -254,21 +329,28 @@ void InterfaceTask::run() {
 
     // Interface loop:
     while (1) {
-        PB_SmartKnobState state;
-        if (xQueueReceive(knob_state_queue_, &state, 0) == pdTRUE) {
-            current_protocol->handleState(state);
+        if (xQueueReceive(knob_state_queue_, &latest_state_, 0) == pdTRUE) {
+            publishState();
         }
 
-        current_protocol->loop();
+        current_protocol_->loop();
 
         std::string* log_string;
         while (xQueueReceive(log_queue_, &log_string, 0) == pdTRUE) {
-            current_protocol->log(log_string->c_str());
+            current_protocol_->log(log_string->c_str());
             delete log_string;
         }
 
         updateHardware();
 
+        if (!configuration_loaded_) {
+            SemaphoreGuard lock(mutex_);
+            if (configuration_ != nullptr) {
+                configuration_value_ = configuration_->get();
+                configuration_loaded_ = true;
+            }
+        }
+
         delay(1);
     }
 }
@@ -292,10 +374,9 @@ void InterfaceTask::changeConfig(bool next) {
         }
     }
     
-    char buf_[256];
     snprintf(buf_, sizeof(buf_), "Changing config to %d -- %s", current_config_, configs[current_config_].text);
     log(buf_);
-    motor_task_.setConfig(configs[current_config_]);
+    applyConfig(configs[current_config_], false);
 }
 
 void InterfaceTask::updateHardware() {
@@ -308,49 +389,51 @@ void InterfaceTask::updateHardware() {
         float lux = veml.readLux();
         lux_avg = lux * LUX_ALPHA + lux_avg * (1 - LUX_ALPHA);
         static uint32_t last_als;
-        if (millis() - last_als > 1000) {
+        if (millis() - last_als > 1000 && strain_calibration_step_ == 0) {
             snprintf(buf_, sizeof(buf_), "millilux: %.2f", lux*1000);
             log(buf_);
             last_als = millis();
         }
     #endif
 
+    static bool pressed;
     #if SK_STRAIN
         if (scale.wait_ready_timeout(100)) {
-            int32_t reading = scale.read();
+            strain_reading_ = scale.read();
 
             static uint32_t last_reading_display;
-            if (millis() - last_reading_display > 1000) {
-                snprintf(buf_, sizeof(buf_), "HX711 reading: %d", reading);
+            if (millis() - last_reading_display > 1000 && strain_calibration_step_ == 0) {
+                snprintf(buf_, sizeof(buf_), "HX711 reading: %d", strain_reading_);
                 log(buf_);
                 last_reading_display = millis();
             }
-
-            // TODO: calibrate and track (long term moving average) zero point (lower); allow calibration of set point offset
-            const int32_t lower = 950000;
-            const int32_t upper = 1800000;
-            // Ignore readings that are way out of expected bounds
-            if (reading >= lower - (upper - lower) && reading < upper + (upper - lower)*2) {
-                long value = CLAMP(reading, lower, upper);
-                press_value_unit = 1. * (value - lower) / (upper - lower);
-
-                static bool pressed;
-                static uint8_t press_count;
-                if (!pressed && press_value_unit > 0.75) {
-                    press_count++;
-                    if (press_count > 2) {
-                        motor_task_.playHaptic(true);
-                        pressed = true;
-                        changeConfig(true);
+            if (configuration_loaded_ && configuration_value_.has_strain && strain_calibration_step_ == 0) {
+                // TODO: calibrate and track (long term moving average) idle point (lower)
+                press_value_unit = lerp(strain_reading_, configuration_value_.strain.idle_value, configuration_value_.strain.idle_value + configuration_value_.strain.press_delta, 0, 1);
+
+                // Ignore readings that are way out of expected bounds
+                if (-1 < press_value_unit && press_value_unit < 2) {
+                    static uint8_t press_readings;
+                    if (!pressed && press_value_unit > 1) {
+                        press_readings++;
+                        if (press_readings > 2) {
+                            motor_task_.playHaptic(true);
+                            pressed = true;
+                            press_count_++;
+                            publishState();
+                            if (!remote_controlled_) {
+                                changeConfig(true);
+                            }
+                        }
+                    } else if (pressed && press_value_unit < 0.5) {
+                        press_readings++;
+                        if (press_readings > 2) {
+                            motor_task_.playHaptic(false);
+                            pressed = false;
+                        }
+                    } else {
+                        press_readings = 0;
                     }
-                } else if (pressed && press_value_unit < 0.25) {
-                    press_count++;
-                    if (press_count > 2) {
-                        motor_task_.playHaptic(false);
-                        pressed = false;
-                    }
-                } else {
-                    press_count = 0;
                 }
             }
         } else {
@@ -377,7 +460,7 @@ void InterfaceTask::updateHardware() {
 
     #if SK_LEDS
         for (uint8_t i = 0; i < NUM_LEDS; i++) {
-            leds[i].setHSV(200 * press_value_unit, 255, brightness >> 8);
+            leds[i].setHSV(latest_config_.led_hue, 255 - 180*CLAMP(press_value_unit, (float)0, (float)1) - 75*pressed, brightness >> 8);
 
             // Gamma adjustment
             leds[i].r = dim8_video(leds[i].r);
@@ -387,3 +470,20 @@ void InterfaceTask::updateHardware() {
         FastLED.show();
     #endif
 }
+
+void InterfaceTask::setConfiguration(Configuration* configuration) {
+    SemaphoreGuard lock(mutex_);
+    configuration_ = configuration;
+}
+
+void InterfaceTask::publishState() {
+    // Apply local state before publishing to serial
+    latest_state_.press_nonce = press_count_;
+    current_protocol_->handleState(latest_state_);
+}
+
+void InterfaceTask::applyConfig(PB_SmartKnobConfig& config, bool from_remote) {
+    remote_controlled_ = from_remote;
+    latest_config_ = config;
+    motor_task_.setConfig(config);
+}

+ 29 - 2
firmware/src/interface_task.h

@@ -3,6 +3,7 @@
 #include <AceButton.h>
 #include <Arduino.h>
 
+#include "configuration.h"
 #include "display_task.h"
 #include "logger.h"
 #include "motor_task.h"
@@ -11,25 +12,49 @@
 #include "serial/uart_stream.h"
 #include "task.h"
 
+#ifndef SK_FORCE_UART_STREAM
+    #define SK_FORCE_UART_STREAM 0
+#endif
+
 class InterfaceTask : public Task<InterfaceTask>, public Logger {
     friend class Task<InterfaceTask>; // Allow base Task to invoke protected run()
 
     public:
         InterfaceTask(const uint8_t task_core, MotorTask& motor_task, DisplayTask* display_task);
-        virtual ~InterfaceTask() {};
+        virtual ~InterfaceTask();
 
         void log(const char* msg) override;
+        void setConfiguration(Configuration* configuration);
 
     protected:
         void run();
 
     private:
+    #if defined(CONFIG_IDF_TARGET_ESP32S3) && !SK_FORCE_UART_STREAM
+        HWCDC stream_;
+    #else
         UartStream stream_;
+    #endif
         MotorTask& motor_task_;
         DisplayTask* display_task_;
-        char buf_[64];
+        char buf_[128];
+
+        SemaphoreHandle_t mutex_;
+        Configuration* configuration_ = nullptr; // protected by mutex_
+
+        PB_PersistentConfiguration configuration_value_;
+        bool configuration_loaded_ = false;
 
+        uint8_t strain_calibration_step_ = 0;
+        int32_t strain_reading_ = 0;
+
+        SerialProtocol* current_protocol_ = nullptr;
+        bool remote_controlled_ = false;
         int current_config_ = 0;
+        uint8_t press_count_ = 1;
+
+        PB_SmartKnobState latest_state_ = {};
+        PB_SmartKnobConfig latest_config_ = {};
 
         QueueHandle_t log_queue_;
         QueueHandle_t knob_state_queue_;
@@ -38,4 +63,6 @@ class InterfaceTask : public Task<InterfaceTask>, public Logger {
 
         void changeConfig(bool next);
         void updateHardware();
+        void publishState();
+        void applyConfig(PB_SmartKnobConfig& config, bool from_remote);
 };

+ 11 - 2
firmware/src/main.cpp

@@ -1,16 +1,19 @@
 #include <Arduino.h>
 
+#include "configuration.h"
 #include "display_task.h"
 #include "interface_task.h"
 #include "motor_task.h"
 
+Configuration config;
+
 #if SK_DISPLAY
 static DisplayTask display_task(0);
 static DisplayTask* display_task_p = &display_task;
 #else
 static DisplayTask* display_task_p = nullptr;
 #endif
-static MotorTask motor_task(1);
+static MotorTask motor_task(1, config);
 
 
 InterfaceTask interface_task(0, motor_task, display_task_p);
@@ -24,9 +27,15 @@ void setup() {
   motor_task.addListener(display_task.getKnobStateQueue());
   #endif
 
+  interface_task.begin();
+
+  config.setLogger(&interface_task);
+  config.loadFromDisk();
+
+  interface_task.setConfiguration(&config);
+
   motor_task.setLogger(&interface_task);
   motor_task.begin();
-  interface_task.begin();
 
   // Free up the Arduino loop task
   vTaskDelete(NULL);

+ 13 - 0
firmware/src/maq430_sensor.h

@@ -0,0 +1,13 @@
+#pragma once
+#include <sensors/MagneticSensorSPI.h>
+
+/** Configured fro 12bit MA710 and MAQ430 magnetic sensor over SPI interface*/
+MagneticSensorSPIConfig_s MAQ430_SPI = {
+  .spi_mode = SPI_MODE3,
+  .clock_speed = 1000000,
+  .bit_resolution = 12,
+  .angle_register = 0x0000,
+  .data_start_bit = 15,
+  .command_rw_bit = 0,  // not required
+  .command_parity_bit = 17 // parity not implemented
+};

+ 109 - 59
firmware/src/motor_task.cpp

@@ -3,21 +3,14 @@
 #include "motor_task.h"
 #if SENSOR_MT6701
 #include "mt6701_sensor.h"
-#endif
-#if SENSOR_TLV
+#elif SENSOR_TLV
 #include "tlv_sensor.h"
+#elif SENSOR_MAQ430
+#include "maq430_sensor.h"
 #endif
-#include "util.h"
-
-
-// #### 
-// Hardware-specific motor calibration constants.
-// Run calibration once at startup, then update these constants with the calibration results.
-static const float ZERO_ELECTRICAL_OFFSET = 7.61;
-static const Direction FOC_DIRECTION = Direction::CW;
-static const int MOTOR_POLE_PAIRS = 7;
-// ####
 
+#include "motors/motor_config.h"
+#include "util.h"
 
 static const float DEAD_ZONE_DETENT_PERCENT = 0.2;
 static const float DEAD_ZONE_RAD = 1 * _PI / 180;
@@ -29,7 +22,7 @@ static const float IDLE_CORRECTION_MAX_ANGLE_RAD = 5 * PI / 180;
 static const float IDLE_CORRECTION_RATE_ALPHA = 0.0005;
 
 
-MotorTask::MotorTask(const uint8_t task_core) : Task("Motor", 2500, 1, task_core) {
+MotorTask::MotorTask(const uint8_t task_core, Configuration& configuration) : Task("Motor", 4000, 1, task_core), configuration_(configuration) {
     queue_ = xQueueCreate(5, sizeof(Command));
     assert(queue_ != NULL);
 }
@@ -41,6 +34,8 @@ MotorTask::~MotorTask() {}
     TlvSensor encoder = TlvSensor();
 #elif SENSOR_MT6701
     MT6701Sensor encoder = MT6701Sensor();
+#elif SENSOR_MAQ430
+    MagneticSensorSPI encoder = MagneticSensorSPI(MAQ430_SPI, PIN_MAQ_SS);
 #endif
 
 void MotorTask::run() {
@@ -50,34 +45,41 @@ void MotorTask::run() {
 
     #if SENSOR_TLV
     encoder.init(&Wire, false);
-    #endif
-
-    #if SENSOR_MT6701
+    #elif SENSOR_MT6701
     encoder.init();
+    #elif SENSOR_MAQ430
+    SPIClass* spi = new SPIClass(HSPI);
+    spi->begin(PIN_MAQ_SCK, PIN_MAQ_MISO, PIN_MAQ_MOSI, PIN_MAQ_SS);
+    encoder.init(spi);
     #endif
 
     motor.linkDriver(&driver);
 
     motor.controller = MotionControlType::torque;
-    motor.voltage_limit = 5;
+    motor.voltage_limit = FOC_VOLTAGE_LIMIT;
     motor.velocity_limit = 10000;
     motor.linkSensor(&encoder);
 
     // Not actually using the velocity loop built into SimpleFOC; but I'm using those PID variables
     // to run PID for torque (and SimpleFOC studio supports updating them easily over serial for tuning)
-    motor.PID_velocity.P = 4;
-    motor.PID_velocity.I = 0;
-    motor.PID_velocity.D = 0.04;
-    motor.PID_velocity.output_ramp = 10000;
-    motor.PID_velocity.limit = 10;
+    motor.PID_velocity.P = FOC_PID_P;
+    motor.PID_velocity.I = FOC_PID_I;
+    motor.PID_velocity.D = FOC_PID_D;
+    motor.PID_velocity.output_ramp = FOC_PID_OUTPUT_RAMP;
+    motor.PID_velocity.limit = FOC_PID_LIMIT;
+
+    #ifdef FOC_LPF
+    motor.LPF_angle.Tf = FOC_LPF;
+    #endif
 
     motor.init();
 
     encoder.update();
     delay(10);
 
-    motor.pole_pairs = MOTOR_POLE_PAIRS;
-    motor.initFOC(ZERO_ELECTRICAL_OFFSET, FOC_DIRECTION);
+    PB_PersistentConfiguration c = configuration_.get();
+    motor.pole_pairs = c.motor.calibrated ? c.motor.pole_pairs : 7;
+    motor.initFOC(c.motor.zero_electrical_offset, c.motor.direction_cw ? Direction::CW : Direction::CCW);
 
     motor.monitor_downsample = 0; // disable monitor at first - optional
 
@@ -86,11 +88,15 @@ void MotorTask::run() {
     float current_detent_center = motor.shaft_angle;
     PB_SmartKnobConfig config = {
         .position = 0,
+        .sub_position_unit = 0,
+        .position_nonce = 0,
         .min_position = 0,
         .max_position = 1,
         .position_width_radians = 60 * _PI / 180,
         .detent_strength_unit = 0,
     };
+    int32_t current_position = 0;
+    float latest_sub_position_unit = 0;
 
     float idle_check_velocity_ewma = 0;
     uint32_t last_idle_start = 0;
@@ -108,44 +114,60 @@ void MotorTask::run() {
                     break;
                 case CommandType::CONFIG: {
                     // Check new config for validity
-                    if (command.data.config.detent_strength_unit < 0) {
+                    PB_SmartKnobConfig& new_config = command.data.config;
+                    if (new_config.detent_strength_unit < 0) {
                         log("Ignoring invalid config: detent_strength_unit cannot be negative");
                         break;
                     }
-                    if (command.data.config.endstop_strength_unit < 0) {
+                    if (new_config.endstop_strength_unit < 0) {
                         log("Ignoring invalid config: endstop_strength_unit cannot be negative");
                         break;
                     }
-                    if (command.data.config.snap_point < 0.5) {
+                    if (new_config.snap_point < 0.5) {
                         log("Ignoring invalid config: snap_point must be >= 0.5 for stability");
                         break;
                     }
-                    if (command.data.config.detent_positions_count > COUNT_OF(command.data.config.detent_positions)) {
+                    if (new_config.detent_positions_count > COUNT_OF(new_config.detent_positions)) {
                         log("Ignoring invalid config: detent_positions_count is too large");
                         break;
                     }
-                    if (command.data.config.snap_point_bias < 0) {
+                    if (new_config.snap_point_bias < 0) {
                         log("Ignoring invalid config: snap_point_bias cannot be negative or there is risk of instability");
                         break;
                     }
 
                     // Change haptic input mode
-                    PB_SmartKnobConfig newConfig = command.data.config;
-                    if (newConfig.position == INT32_MIN) {
-                        // INT32_MIN indicates no change to position, so restore from latest_config
-                        log("maintaining position");
-                        newConfig.position = config.position;
+                    bool position_updated = false;
+                    if (new_config.position != config.position
+                            || new_config.sub_position_unit != config.sub_position_unit
+                            || new_config.position_nonce != config.position_nonce) {
+                        log("applying position change");
+                        current_position = new_config.position;
+                        position_updated = true;
+                    }
+
+                    if (new_config.min_position <= new_config.max_position) {
+                        // Only check bounds if min/max indicate bounds are active (min >= max)
+                        if (current_position < new_config.min_position) {
+                            current_position = new_config.min_position;
+                            log("adjusting position to min");
+                        } else if (current_position > new_config.max_position) {
+                            current_position = new_config.max_position;
+                            log("adjusting position to max");
+                        }
                     }
-                    if (newConfig.position != config.position
-                            || newConfig.position_width_radians != config.position_width_radians) {
-                        // Only adjust the detent center if the position or width has changed
+
+                    if (position_updated || new_config.position_width_radians != config.position_width_radians) {
                         log("adjusting detent center");
-                        current_detent_center = motor.shaft_angle;
+                        float new_sub_position = position_updated ? new_config.sub_position_unit : latest_sub_position_unit;
                         #if SK_INVERT_ROTATION
-                            current_detent_center = -motor.shaft_angle;
+                            float shaft_angle = -motor.shaft_angle;
+                        #else
+                            float shaft_angle = motor.shaft_angle;
                         #endif
+                        current_detent_center = shaft_angle + new_sub_position * new_config.position_width_radians;
                     }
-                    config = newConfig;
+                    config = new_config;
                     log("Got new config");
 
                     // Update derivative factor of torque controller based on detent width.
@@ -211,26 +233,28 @@ void MotorTask::run() {
 
         float snap_point_radians = config.position_width_radians * config.snap_point;
         float bias_radians = config.position_width_radians * config.snap_point_bias;
-        float snap_point_radians_decrease = snap_point_radians + (config.position <= 0 ? bias_radians : -bias_radians);
-        float snap_point_radians_increase = -snap_point_radians + (config.position >= 0 ? -bias_radians : bias_radians); 
+        float snap_point_radians_decrease = snap_point_radians + (current_position <= 0 ? bias_radians : -bias_radians);
+        float snap_point_radians_increase = -snap_point_radians + (current_position >= 0 ? -bias_radians : bias_radians); 
 
         int32_t num_positions = config.max_position - config.min_position + 1;
-        if (angle_to_detent_center > snap_point_radians_decrease && (num_positions <= 0 || config.position > config.min_position)) {
+        if (angle_to_detent_center > snap_point_radians_decrease && (num_positions <= 0 || current_position > config.min_position)) {
             current_detent_center += config.position_width_radians;
             angle_to_detent_center -= config.position_width_radians;
-            config.position--;
-        } else if (angle_to_detent_center < snap_point_radians_increase && (num_positions <= 0 || config.position < config.max_position)) {
+            current_position--;
+        } else if (angle_to_detent_center < snap_point_radians_increase && (num_positions <= 0 || current_position < config.max_position)) {
             current_detent_center -= config.position_width_radians;
             angle_to_detent_center += config.position_width_radians;
-            config.position++;
+            current_position++;
         }
 
+        latest_sub_position_unit = -angle_to_detent_center / config.position_width_radians;
+
         float dead_zone_adjustment = CLAMP(
             angle_to_detent_center,
             fmaxf(-config.position_width_radians*DEAD_ZONE_DETENT_PERCENT, -DEAD_ZONE_RAD),
             fminf(config.position_width_radians*DEAD_ZONE_DETENT_PERCENT, DEAD_ZONE_RAD));
 
-        bool out_of_bounds = num_positions > 0 && ((angle_to_detent_center > 0 && config.position == config.min_position) || (angle_to_detent_center < 0 && config.position == config.max_position));
+        bool out_of_bounds = num_positions > 0 && ((angle_to_detent_center > 0 && current_position == config.min_position) || (angle_to_detent_center < 0 && current_position == config.max_position));
         motor.PID_velocity.limit = 10; //out_of_bounds ? 10 : 3;
         motor.PID_velocity.P = out_of_bounds ? config.endstop_strength_unit * 4 : config.detent_strength_unit * 4;
 
@@ -244,7 +268,7 @@ void MotorTask::run() {
             if (!out_of_bounds && config.detent_positions_count > 0) {
                 bool in_detent = false;
                 for (uint8_t i = 0; i < config.detent_positions_count; i++) {
-                    if (config.detent_positions[i] == config.position) {
+                    if (config.detent_positions[i] == current_position) {
                         in_detent = true;
                         break;
                     }
@@ -263,16 +287,14 @@ void MotorTask::run() {
         // Publish current status to other registered tasks periodically
         if (millis() - last_publish > 5) {
             publish({
-                .current_position = config.position,
-                .sub_position_unit = -angle_to_detent_center / config.position_width_radians,
+                .current_position = current_position,
+                .sub_position_unit = latest_sub_position_unit,
                 .has_config = true,
                 .config = config,
             });
             last_publish = millis();
         }
 
-        motor.monitor();
-
         delay(1);
     }
 }
@@ -328,6 +350,7 @@ void MotorTask::calibrate() {
     // TODO: dig into SimpleFOC calibration and find/fix the issue
 
     log("\n\n\nStarting calibration, please DO NOT TOUCH MOTOR until complete!");
+    delay(1000);
 
     motor.controller = MotionControlType::angle_openloop;
     motor.pole_pairs = 1;
@@ -335,6 +358,9 @@ void MotorTask::calibrate() {
 
     float a = 0;
 
+    motor.voltage_limit = FOC_VOLTAGE_LIMIT;
+    motor.move(a);
+
     // #### Determine direction motor rotates relative to angle sensor
     for (uint8_t i = 0; i < 200; i++) {
         encoder.update();
@@ -361,7 +387,12 @@ void MotorTask::calibrate() {
 
     log("");
 
-    // TODO: check for no motor movement!
+    float movement_angle = fabsf(end_sensor - start_sensor);
+    if (movement_angle < radians(30) || movement_angle > radians(180)) {
+        snprintf(buf_, sizeof(buf_), "ERROR! Unexpected sensor change: start=%.2f end=%.2f", start_sensor, end_sensor);
+        log(buf_);
+        return;
+    }
 
     log("Sensor measures positive for positive motor rotation:");
     if (end_sensor > start_sensor) {
@@ -371,6 +402,8 @@ void MotorTask::calibrate() {
         log("NO, Direction=CCW");
         motor.initFOC(0, Direction::CCW);
     }
+    snprintf(buf_, sizeof(buf_), "  (start was %.1f, end was %.1f)", start_sensor, end_sensor);
+    log(buf_);
 
 
     // #### Determine pole-pairs
@@ -378,7 +411,7 @@ void MotorTask::calibrate() {
     uint8_t electrical_revolutions = 20;
     snprintf(buf_, sizeof(buf_), "Going to measure %d electrical revolutions...", electrical_revolutions);
     log(buf_);
-    motor.voltage_limit = 5;
+    motor.voltage_limit = FOC_VOLTAGE_LIMIT;
     motor.move(a);
     log("Going to electrical zero...");
     float destination = a + _2PI;
@@ -419,6 +452,12 @@ void MotorTask::calibrate() {
     snprintf(buf_, sizeof(buf_), "Electrical angle / mechanical angle (i.e. pole pairs) = %.2f", electrical_per_mechanical);
     log(buf_);
 
+    if (electrical_per_mechanical < 3 || electrical_per_mechanical > 12) {
+        snprintf(buf_, sizeof(buf_), "ERROR! Unexpected calculated pole pairs: %.2f", electrical_per_mechanical);
+        log(buf_);
+        return;
+    }
+
     int measured_pole_pairs = (int)round(electrical_per_mechanical);
     snprintf(buf_, sizeof(buf_), "Pole pairs set to %d", measured_pole_pairs);
     log(buf_);
@@ -428,7 +467,7 @@ void MotorTask::calibrate() {
 
     // #### Determine mechanical offset to electrical zero
     // Measure mechanical angle at every electrical zero for several revolutions
-    motor.voltage_limit = 5;
+    motor.voltage_limit = FOC_VOLTAGE_LIMIT;
     motor.move(a);
     float offset_x = 0;
     float offset_y = 0;
@@ -475,13 +514,13 @@ void MotorTask::calibrate() {
 
 
     // #### Apply settings
-    // TODO: save to non-volatile storage
     motor.pole_pairs = measured_pole_pairs;
     motor.zero_electric_angle = avg_offset_angle + _3PI_2;
-    motor.voltage_limit = 5;
+    motor.voltage_limit = FOC_VOLTAGE_LIMIT;
     motor.controller = MotionControlType::torque;
 
-    log("\n\nRESULTS:\n  Update these constants at the top of " __FILE__);
+    log("");
+    log("RESULTS:");
     snprintf(buf_, sizeof(buf_), "  ZERO_ELECTRICAL_OFFSET: %.2f", motor.zero_electric_angle);
     log(buf_);
     if (motor.sensor_direction == Direction::CW) {
@@ -491,7 +530,18 @@ void MotorTask::calibrate() {
     }
     snprintf(buf_, sizeof(buf_), "  MOTOR_POLE_PAIRS: %d", motor.pole_pairs);
     log(buf_);
-    delay(2000);
+
+    log("");
+    log("Saving to persistent configuration...");
+    PB_MotorCalibration calibration = {
+        .calibrated = true,
+        .zero_electrical_offset = motor.zero_electric_angle,
+        .direction_cw = motor.sensor_direction == Direction::CW,
+        .pole_pairs = motor.pole_pairs,
+    };
+    if (configuration_.setMotorCalibrationAndSave(calibration)) {
+        log("Success!");
+    }
 }
 
 void MotorTask::checkSensorError() {

+ 3 - 1
firmware/src/motor_task.h

@@ -4,6 +4,7 @@
 #include <SimpleFOC.h>
 #include <vector>
 
+#include "configuration.h"
 #include "logger.h"
 #include "proto_gen/smartknob.pb.h"
 #include "task.h"
@@ -33,7 +34,7 @@ class MotorTask : public Task<MotorTask> {
     friend class Task<MotorTask>; // Allow base Task to invoke protected run()
 
     public:
-        MotorTask(const uint8_t task_core);
+        MotorTask(const uint8_t task_core, Configuration& configuration);
         ~MotorTask();
 
         void setConfig(const PB_SmartKnobConfig& config);
@@ -47,6 +48,7 @@ class MotorTask : public Task<MotorTask> {
         void run();
 
     private:
+        Configuration& configuration_;
         QueueHandle_t queue_;
         Logger* logger_;
         std::vector<QueueHandle_t> listeners_;

+ 12 - 0
firmware/src/motors/mad2804.h

@@ -0,0 +1,12 @@
+#pragma once
+
+// Tuning parameters for the MAD2804 motor (orange stator).
+
+#define FOC_PID_P 1
+#define FOC_PID_I 0
+#define FOC_PID_D 0.148
+#define FOC_PID_OUTPUT_RAMP 5000
+#define FOC_PID_LIMIT 3
+
+#define FOC_VOLTAGE_LIMIT 3
+#define FOC_LPF 0.0075

+ 9 - 0
firmware/src/motors/motor_config.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#if MOTOR_WANZHIDA_ONCE_TOP
+#include "motors/wanzhida_once_top.h"
+#elif MOTOR_MAD2804
+#include "motors/mad2804.h"
+#else
+#error "No motor configuration specified!"
+#endif

+ 12 - 0
firmware/src/motors/wanzhida_once_top.h

@@ -0,0 +1,12 @@
+#pragma once
+
+// Tuning parameters for the Wanzhida/Oncetop OT-EM3215D2450Y1R,
+// sold by SparkFun (ROB-20441).
+
+#define FOC_PID_P 4
+#define FOC_PID_I 0
+#define FOC_PID_D 0.04
+#define FOC_PID_OUTPUT_RAMP 10000
+#define FOC_PID_LIMIT 10
+
+#define FOC_VOLTAGE_LIMIT 5

+ 13 - 2
firmware/src/mt6701_sensor.cpp

@@ -51,7 +51,13 @@ void MT6701Sensor::init() {
       .quadhd_io_num = -1,
       .max_transfer_sz = 1000,
   };
-  esp_err_t ret = spi_bus_initialize(HSPI_HOST, &tx_bus_config, 1);
+  
+  #ifdef CONFIG_IDF_TARGET_ESP32S3
+    esp_err_t ret = spi_bus_initialize(SPI3_HOST, &tx_bus_config, SPI_DMA_CH_AUTO);
+  #else
+    esp_err_t ret = spi_bus_initialize(HSPI_HOST, &tx_bus_config, 1);
+  #endif
+  
   ESP_ERROR_CHECK(ret);
 
   spi_device_interface_config_t tx_device_config = {
@@ -70,7 +76,12 @@ void MT6701Sensor::init() {
       .pre_cb=NULL,
       .post_cb=NULL,
   };
-  ret=spi_bus_add_device(HSPI_HOST, &tx_device_config, &spi_device_);
+  #ifdef CONFIG_IDF_TARGET_ESP32S3
+    ret=spi_bus_add_device(SPI3_HOST, &tx_device_config, &spi_device_);
+  #else
+    ret=spi_bus_add_device(HSPI_HOST, &tx_device_config, &spi_device_);
+  #endif
+  
   ESP_ERROR_CHECK(ret);
 
 

+ 12 - 3
firmware/src/proto_gen/smartknob.pb.c

@@ -9,6 +9,9 @@
 PB_BIND(PB_FromSmartKnob, PB_FromSmartKnob, 2)
 
 
+PB_BIND(PB_ToSmartknob, PB_ToSmartknob, AUTO)
+
+
 PB_BIND(PB_Ack, PB_Ack, AUTO)
 
 
@@ -18,13 +21,19 @@ PB_BIND(PB_Log, PB_Log, 2)
 PB_BIND(PB_SmartKnobState, PB_SmartKnobState, AUTO)
 
 
-PB_BIND(PB_ToSmartknob, PB_ToSmartknob, AUTO)
-
-
 PB_BIND(PB_SmartKnobConfig, PB_SmartKnobConfig, AUTO)
 
 
 PB_BIND(PB_RequestState, PB_RequestState, AUTO)
 
 
+PB_BIND(PB_PersistentConfiguration, PB_PersistentConfiguration, AUTO)
+
+
+PB_BIND(PB_MotorCalibration, PB_MotorCalibration, AUTO)
+
+
+PB_BIND(PB_StrainCalibration, PB_StrainCalibration, AUTO)
+
+
 

+ 248 - 53
firmware/src/proto_gen/smartknob.pb.h

@@ -10,6 +10,7 @@
 #endif
 
 /* Struct definitions */
+/* * Lets the host know that a ToSmartknob message was received and should not be retried. */
 typedef struct _PB_Ack {
     uint32_t nonce;
 } PB_Ack;
@@ -19,28 +20,142 @@ typedef struct _PB_Log {
 } PB_Log;
 
 typedef struct _PB_SmartKnobConfig {
+    /* *
+ Set the integer position.
+
+ Note: in order to make SmartKnobConfig apply idempotently, the current position
+ will only be set to this value when it changes compared to a previous config (and
+ NOT compared to the current state!). So by default, if you send a config position
+ of 5 and the current position is 3, the position may remain at 3 if the config
+ change to 5 was previously handled. If you need to force a position update, see
+ position_nonce. */
     int32_t position;
+    /* *
+ Set the fractional position. Typical range: (-snap_point, snap_point).
+
+ Actual range is technically unbounded, but in practice this value will be compared
+ against snap_point on the next control loop, so any value beyond the snap_point will
+ generally result in an integer position change (unless position is already at a
+ limit).
+
+ Note: idempotency implications noted in the documentation for `position` apply here
+ as well */
+    float sub_position_unit;
+    /* *
+ Position is normally only applied when it changes, but sometimes it's desirable
+ to reset the position to the same value, so a nonce change can be used to force
+ the position values to be applied as well.
+
+ NOTE: Must be < 256 */
+    uint8_t position_nonce;
+    /* * Minimum position allowed. */
     int32_t min_position;
+    /* *
+ Maximum position allowed.
+
+ If this is the same as min_position, there will only be one allowed position.
+
+ If this is less than min_position, bounds will be disabled. */
     int32_t max_position;
+    /* * The angular "width" of each position/detent, in radians. */
     float position_width_radians;
+    /* *
+ Strength of detents to apply. Typical range: [0, 1].
+
+ A value of 0 disables detents.
+
+ Values greater than 1 are not recommended and may lead to unstable behavior. */
     float detent_strength_unit;
+    /* *
+ Strength of endstop torque to apply at min/max bounds. Typical range: [0, 1].
+
+ A value of 0 disables endstop torque, but does not make position unbounded, meaning
+ the knob will not try to return to the valid region. For unbounded rotation, use
+ min_position and max_position.
+
+ Values greater than 1 are not recommended and may lead to unstable behavior. */
     float endstop_strength_unit;
+    /* *
+ Fractional (sub-position) threshold where the position will increment/decrement.
+ Typical range: (0.5, 1.5).
+
+ This defines how hysteresis is applied to positions, which is why values > */
     float snap_point;
+    /* *
+ Arbitrary 50-byte string representing this "config". This can be used to identify major
+ config/mode changes. The value will be echoed back to the host via a future State's
+ embedded config field so the host can use this value to determine the mode that was
+ in effect at the time of the State snapshot instead of having to infer it from the
+ other config fields. */
     char text[51];
+    /* *
+ For a "magnetic" detent mode - where not all positions should have detents - this
+ specifies which positions (up to 5) have detents enabled. The knob will feel like it
+ is "magnetically" attracted to those positions, and will rotate smoothy past all
+ other positions.
+
+ If you want to have more than 5 magnetic detent positions, you will need to dynamically
+ update this list as the knob is rotated. A recommended approach is to always send the
+ _nearest_ 5 detent positions, and send a new Config message whenever the list of
+ positions nearest the current position (as reported via State messages) changes.
+
+ This approach enables effectively unbounded detent positions while keeping Config
+ bounded in size, and is resilient against tightly-packed detents with fast rotation
+ since multiple detent positions can be sent in advance; a full round-trip Config-State
+ isn't needed between each detent in order to keep up. */
     pb_size_t detent_positions_count;
     int32_t detent_positions[5];
+    /* *
+ Advanced feature for shifting the defined snap_point away from the center (position 0)
+ for implementing asymmetric detents. Typical value: 0 (symmetric detent force).
+
+ This can be used to create detents that will hold the position when carefully released,
+ but can be easily disturbed to return "home" towards position 0. */
     float snap_point_bias;
+    /* *
+ Hue (0-255) for all 8 ring LEDs, if supported. Note: this will likely be replaced
+ with more configurability in a future protocol version. */
+    int16_t led_hue;
 } PB_SmartKnobConfig;
 
 typedef struct _PB_SmartKnobState {
+    /* * Current integer position of the knob. (Detent resolution is at integer positions) */
     int32_t current_position;
+    /* *
+ Current fractional position. Typically will only range from (-snap_point, snap_point)
+ since further rotation will result in the integer position changing, but may exceed
+ those values if snap_point_bias is non-zero, or if the knob is at a bound. When the
+ knob is at a bound, this value can grow endlessly as the knob is rotated further past
+ the bound.
+
+ When visualizing sub_position_unit, you will likely want to apply a rubber-band easing
+ function past the bounds; a sublinear relationship will help suggest that a bound has
+ been reached. */
     float sub_position_unit;
+    /* *
+ Current SmartKnobConfig in effect at the time of this State snapshot.
+
+ Beware that this config contains position and sub_position_unit values, not to be
+ confused with the top level current_position and sub_position_unit values in this State
+ message. The position values in the embedded config message will almost never be useful
+ to you; you probably want to be reading the top level values from the State message. */
     bool has_config;
     PB_SmartKnobConfig config;
+    /* *
+ Value that changes each time the knob is pressed. Does not change when a press is released.
+
+ Why this press state a "nonce" rather than a simple boolean representing the current
+ "pressed" state? It makes the protocol more robust to dropped/lost State messages; if
+ the knob was pressed/released quickly and State messages happened to be dropped during
+ that time, the press would be completely lost. Using a nonce allows the host to recognize
+ that a press has taken place at some point even if the State was lost during the press
+ itself. Is this overkill? Probably, let's revisit in future protocol versions. */
+    uint8_t press_nonce;
 } PB_SmartKnobState;
 
-/* Message FROM the SmartKnob to USB host */
+/* Message FROM the SmartKnob to the host */
 typedef struct _PB_FromSmartKnob {
+    uint8_t protocol_version;
     pb_size_t which_payload;
     union {
         PB_Ack ack;
@@ -53,8 +168,9 @@ typedef struct _PB_RequestState {
     char dummy_field;
 } PB_RequestState;
 
-/* Message TO the Smartknob from the USB host */
+/* Message TO the Smartknob from the host */
 typedef struct _PB_ToSmartknob {
+    uint8_t protocol_version;
     uint32_t nonce;
     pb_size_t which_payload;
     union {
@@ -63,61 +179,113 @@ typedef struct _PB_ToSmartknob {
     } payload;
 } PB_ToSmartknob;
 
+typedef struct _PB_MotorCalibration {
+    bool calibrated;
+    float zero_electrical_offset;
+    bool direction_cw;
+    uint32_t pole_pairs;
+} PB_MotorCalibration;
+
+typedef struct _PB_StrainCalibration {
+    int32_t idle_value;
+    int32_t press_delta;
+} PB_StrainCalibration;
+
+typedef struct _PB_PersistentConfiguration {
+    uint32_t version;
+    bool has_motor;
+    PB_MotorCalibration motor;
+    bool has_strain;
+    PB_StrainCalibration strain;
+} PB_PersistentConfiguration;
+
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 /* Initializer values for message structs */
-#define PB_FromSmartKnob_init_default            {0, {PB_Ack_init_default}}
+#define PB_FromSmartKnob_init_default            {0, 0, {PB_Ack_init_default}}
+#define PB_ToSmartknob_init_default              {0, 0, 0, {PB_RequestState_init_default}}
 #define PB_Ack_init_default                      {0}
 #define PB_Log_init_default                      {""}
-#define PB_SmartKnobState_init_default           {0, 0, false, PB_SmartKnobConfig_init_default}
-#define PB_ToSmartknob_init_default              {0, 0, {PB_RequestState_init_default}}
-#define PB_SmartKnobConfig_init_default          {0, 0, 0, 0, 0, 0, 0, "", 0, {0, 0, 0, 0, 0}, 0}
+#define PB_SmartKnobState_init_default           {0, 0, false, PB_SmartKnobConfig_init_default, 0}
+#define PB_SmartKnobConfig_init_default          {0, 0, 0, 0, 0, 0, 0, 0, 0, "", 0, {0, 0, 0, 0, 0}, 0, 0}
 #define PB_RequestState_init_default             {0}
-#define PB_FromSmartKnob_init_zero               {0, {PB_Ack_init_zero}}
+#define PB_PersistentConfiguration_init_default  {0, false, PB_MotorCalibration_init_default, false, PB_StrainCalibration_init_default}
+#define PB_MotorCalibration_init_default         {0, 0, 0, 0}
+#define PB_StrainCalibration_init_default        {0, 0}
+#define PB_FromSmartKnob_init_zero               {0, 0, {PB_Ack_init_zero}}
+#define PB_ToSmartknob_init_zero                 {0, 0, 0, {PB_RequestState_init_zero}}
 #define PB_Ack_init_zero                         {0}
 #define PB_Log_init_zero                         {""}
-#define PB_SmartKnobState_init_zero              {0, 0, false, PB_SmartKnobConfig_init_zero}
-#define PB_ToSmartknob_init_zero                 {0, 0, {PB_RequestState_init_zero}}
-#define PB_SmartKnobConfig_init_zero             {0, 0, 0, 0, 0, 0, 0, "", 0, {0, 0, 0, 0, 0}, 0}
+#define PB_SmartKnobState_init_zero              {0, 0, false, PB_SmartKnobConfig_init_zero, 0}
+#define PB_SmartKnobConfig_init_zero             {0, 0, 0, 0, 0, 0, 0, 0, 0, "", 0, {0, 0, 0, 0, 0}, 0, 0}
 #define PB_RequestState_init_zero                {0}
+#define PB_PersistentConfiguration_init_zero     {0, false, PB_MotorCalibration_init_zero, false, PB_StrainCalibration_init_zero}
+#define PB_MotorCalibration_init_zero            {0, 0, 0, 0}
+#define PB_StrainCalibration_init_zero           {0, 0}
 
 /* Field tags (for use in manual encoding/decoding) */
 #define PB_Ack_nonce_tag                         1
 #define PB_Log_msg_tag                           1
 #define PB_SmartKnobConfig_position_tag          1
-#define PB_SmartKnobConfig_min_position_tag      2
-#define PB_SmartKnobConfig_max_position_tag      3
-#define PB_SmartKnobConfig_position_width_radians_tag 4
-#define PB_SmartKnobConfig_detent_strength_unit_tag 5
-#define PB_SmartKnobConfig_endstop_strength_unit_tag 6
-#define PB_SmartKnobConfig_snap_point_tag        7
-#define PB_SmartKnobConfig_text_tag              8
-#define PB_SmartKnobConfig_detent_positions_tag  9
-#define PB_SmartKnobConfig_snap_point_bias_tag   10
+#define PB_SmartKnobConfig_sub_position_unit_tag 2
+#define PB_SmartKnobConfig_position_nonce_tag    3
+#define PB_SmartKnobConfig_min_position_tag      4
+#define PB_SmartKnobConfig_max_position_tag      5
+#define PB_SmartKnobConfig_position_width_radians_tag 6
+#define PB_SmartKnobConfig_detent_strength_unit_tag 7
+#define PB_SmartKnobConfig_endstop_strength_unit_tag 8
+#define PB_SmartKnobConfig_snap_point_tag        9
+#define PB_SmartKnobConfig_text_tag              10
+#define PB_SmartKnobConfig_detent_positions_tag  11
+#define PB_SmartKnobConfig_snap_point_bias_tag   12
+#define PB_SmartKnobConfig_led_hue_tag           13
 #define PB_SmartKnobState_current_position_tag   1
 #define PB_SmartKnobState_sub_position_unit_tag  2
 #define PB_SmartKnobState_config_tag             3
-#define PB_FromSmartKnob_ack_tag                 1
-#define PB_FromSmartKnob_log_tag                 2
-#define PB_FromSmartKnob_smartknob_state_tag     3
-#define PB_ToSmartknob_nonce_tag                 1
-#define PB_ToSmartknob_request_state_tag         2
-#define PB_ToSmartknob_smartknob_config_tag      3
+#define PB_SmartKnobState_press_nonce_tag        4
+#define PB_FromSmartKnob_protocol_version_tag    1
+#define PB_FromSmartKnob_ack_tag                 2
+#define PB_FromSmartKnob_log_tag                 3
+#define PB_FromSmartKnob_smartknob_state_tag     4
+#define PB_ToSmartknob_protocol_version_tag      1
+#define PB_ToSmartknob_nonce_tag                 2
+#define PB_ToSmartknob_request_state_tag         3
+#define PB_ToSmartknob_smartknob_config_tag      4
+#define PB_MotorCalibration_calibrated_tag       1
+#define PB_MotorCalibration_zero_electrical_offset_tag 2
+#define PB_MotorCalibration_direction_cw_tag     3
+#define PB_MotorCalibration_pole_pairs_tag       4
+#define PB_StrainCalibration_idle_value_tag      1
+#define PB_StrainCalibration_press_delta_tag     2
+#define PB_PersistentConfiguration_version_tag   1
+#define PB_PersistentConfiguration_motor_tag     2
+#define PB_PersistentConfiguration_strain_tag    3
 
 /* Struct field encoding specification for nanopb */
 #define PB_FromSmartKnob_FIELDLIST(X, a) \
-X(a, STATIC,   ONEOF,    MESSAGE,  (payload,ack,payload.ack),   1) \
-X(a, STATIC,   ONEOF,    MESSAGE,  (payload,log,payload.log),   2) \
-X(a, STATIC,   ONEOF,    MESSAGE,  (payload,smartknob_state,payload.smartknob_state),   3)
+X(a, STATIC,   SINGULAR, UINT32,   protocol_version,   1) \
+X(a, STATIC,   ONEOF,    MESSAGE,  (payload,ack,payload.ack),   2) \
+X(a, STATIC,   ONEOF,    MESSAGE,  (payload,log,payload.log),   3) \
+X(a, STATIC,   ONEOF,    MESSAGE,  (payload,smartknob_state,payload.smartknob_state),   4)
 #define PB_FromSmartKnob_CALLBACK NULL
 #define PB_FromSmartKnob_DEFAULT NULL
 #define PB_FromSmartKnob_payload_ack_MSGTYPE PB_Ack
 #define PB_FromSmartKnob_payload_log_MSGTYPE PB_Log
 #define PB_FromSmartKnob_payload_smartknob_state_MSGTYPE PB_SmartKnobState
 
+#define PB_ToSmartknob_FIELDLIST(X, a) \
+X(a, STATIC,   SINGULAR, UINT32,   protocol_version,   1) \
+X(a, STATIC,   SINGULAR, UINT32,   nonce,             2) \
+X(a, STATIC,   ONEOF,    MESSAGE,  (payload,request_state,payload.request_state),   3) \
+X(a, STATIC,   ONEOF,    MESSAGE,  (payload,smartknob_config,payload.smartknob_config),   4)
+#define PB_ToSmartknob_CALLBACK NULL
+#define PB_ToSmartknob_DEFAULT NULL
+#define PB_ToSmartknob_payload_request_state_MSGTYPE PB_RequestState
+#define PB_ToSmartknob_payload_smartknob_config_MSGTYPE PB_SmartKnobConfig
+
 #define PB_Ack_FIELDLIST(X, a) \
 X(a, STATIC,   SINGULAR, UINT32,   nonce,             1)
 #define PB_Ack_CALLBACK NULL
@@ -131,31 +299,26 @@ X(a, STATIC,   SINGULAR, STRING,   msg,               1)
 #define PB_SmartKnobState_FIELDLIST(X, a) \
 X(a, STATIC,   SINGULAR, INT32,    current_position,   1) \
 X(a, STATIC,   SINGULAR, FLOAT,    sub_position_unit,   2) \
-X(a, STATIC,   OPTIONAL, MESSAGE,  config,            3)
+X(a, STATIC,   OPTIONAL, MESSAGE,  config,            3) \
+X(a, STATIC,   SINGULAR, UINT32,   press_nonce,       4)
 #define PB_SmartKnobState_CALLBACK NULL
 #define PB_SmartKnobState_DEFAULT NULL
 #define PB_SmartKnobState_config_MSGTYPE PB_SmartKnobConfig
 
-#define PB_ToSmartknob_FIELDLIST(X, a) \
-X(a, STATIC,   SINGULAR, UINT32,   nonce,             1) \
-X(a, STATIC,   ONEOF,    MESSAGE,  (payload,request_state,payload.request_state),   2) \
-X(a, STATIC,   ONEOF,    MESSAGE,  (payload,smartknob_config,payload.smartknob_config),   3)
-#define PB_ToSmartknob_CALLBACK NULL
-#define PB_ToSmartknob_DEFAULT NULL
-#define PB_ToSmartknob_payload_request_state_MSGTYPE PB_RequestState
-#define PB_ToSmartknob_payload_smartknob_config_MSGTYPE PB_SmartKnobConfig
-
 #define PB_SmartKnobConfig_FIELDLIST(X, a) \
 X(a, STATIC,   SINGULAR, INT32,    position,          1) \
-X(a, STATIC,   SINGULAR, INT32,    min_position,      2) \
-X(a, STATIC,   SINGULAR, INT32,    max_position,      3) \
-X(a, STATIC,   SINGULAR, FLOAT,    position_width_radians,   4) \
-X(a, STATIC,   SINGULAR, FLOAT,    detent_strength_unit,   5) \
-X(a, STATIC,   SINGULAR, FLOAT,    endstop_strength_unit,   6) \
-X(a, STATIC,   SINGULAR, FLOAT,    snap_point,        7) \
-X(a, STATIC,   SINGULAR, STRING,   text,              8) \
-X(a, STATIC,   REPEATED, INT32,    detent_positions,   9) \
-X(a, STATIC,   SINGULAR, FLOAT,    snap_point_bias,  10)
+X(a, STATIC,   SINGULAR, FLOAT,    sub_position_unit,   2) \
+X(a, STATIC,   SINGULAR, UINT32,   position_nonce,    3) \
+X(a, STATIC,   SINGULAR, INT32,    min_position,      4) \
+X(a, STATIC,   SINGULAR, INT32,    max_position,      5) \
+X(a, STATIC,   SINGULAR, FLOAT,    position_width_radians,   6) \
+X(a, STATIC,   SINGULAR, FLOAT,    detent_strength_unit,   7) \
+X(a, STATIC,   SINGULAR, FLOAT,    endstop_strength_unit,   8) \
+X(a, STATIC,   SINGULAR, FLOAT,    snap_point,        9) \
+X(a, STATIC,   SINGULAR, STRING,   text,             10) \
+X(a, STATIC,   REPEATED, INT32,    detent_positions,  11) \
+X(a, STATIC,   SINGULAR, FLOAT,    snap_point_bias,  12) \
+X(a, STATIC,   SINGULAR, INT32,    led_hue,          13)
 #define PB_SmartKnobConfig_CALLBACK NULL
 #define PB_SmartKnobConfig_DEFAULT NULL
 
@@ -164,31 +327,63 @@ X(a, STATIC,   SINGULAR, FLOAT,    snap_point_bias,  10)
 #define PB_RequestState_CALLBACK NULL
 #define PB_RequestState_DEFAULT NULL
 
+#define PB_PersistentConfiguration_FIELDLIST(X, a) \
+X(a, STATIC,   SINGULAR, UINT32,   version,           1) \
+X(a, STATIC,   OPTIONAL, MESSAGE,  motor,             2) \
+X(a, STATIC,   OPTIONAL, MESSAGE,  strain,            3)
+#define PB_PersistentConfiguration_CALLBACK NULL
+#define PB_PersistentConfiguration_DEFAULT NULL
+#define PB_PersistentConfiguration_motor_MSGTYPE PB_MotorCalibration
+#define PB_PersistentConfiguration_strain_MSGTYPE PB_StrainCalibration
+
+#define PB_MotorCalibration_FIELDLIST(X, a) \
+X(a, STATIC,   SINGULAR, BOOL,     calibrated,        1) \
+X(a, STATIC,   SINGULAR, FLOAT,    zero_electrical_offset,   2) \
+X(a, STATIC,   SINGULAR, BOOL,     direction_cw,      3) \
+X(a, STATIC,   SINGULAR, UINT32,   pole_pairs,        4)
+#define PB_MotorCalibration_CALLBACK NULL
+#define PB_MotorCalibration_DEFAULT NULL
+
+#define PB_StrainCalibration_FIELDLIST(X, a) \
+X(a, STATIC,   SINGULAR, INT32,    idle_value,        1) \
+X(a, STATIC,   SINGULAR, INT32,    press_delta,       2)
+#define PB_StrainCalibration_CALLBACK NULL
+#define PB_StrainCalibration_DEFAULT NULL
+
 extern const pb_msgdesc_t PB_FromSmartKnob_msg;
+extern const pb_msgdesc_t PB_ToSmartknob_msg;
 extern const pb_msgdesc_t PB_Ack_msg;
 extern const pb_msgdesc_t PB_Log_msg;
 extern const pb_msgdesc_t PB_SmartKnobState_msg;
-extern const pb_msgdesc_t PB_ToSmartknob_msg;
 extern const pb_msgdesc_t PB_SmartKnobConfig_msg;
 extern const pb_msgdesc_t PB_RequestState_msg;
+extern const pb_msgdesc_t PB_PersistentConfiguration_msg;
+extern const pb_msgdesc_t PB_MotorCalibration_msg;
+extern const pb_msgdesc_t PB_StrainCalibration_msg;
 
 /* Defines for backwards compatibility with code written before nanopb-0.4.0 */
 #define PB_FromSmartKnob_fields &PB_FromSmartKnob_msg
+#define PB_ToSmartknob_fields &PB_ToSmartknob_msg
 #define PB_Ack_fields &PB_Ack_msg
 #define PB_Log_fields &PB_Log_msg
 #define PB_SmartKnobState_fields &PB_SmartKnobState_msg
-#define PB_ToSmartknob_fields &PB_ToSmartknob_msg
 #define PB_SmartKnobConfig_fields &PB_SmartKnobConfig_msg
 #define PB_RequestState_fields &PB_RequestState_msg
+#define PB_PersistentConfiguration_fields &PB_PersistentConfiguration_msg
+#define PB_MotorCalibration_fields &PB_MotorCalibration_msg
+#define PB_StrainCalibration_fields &PB_StrainCalibration_msg
 
 /* Maximum encoded size of messages (where known) */
 #define PB_Ack_size                              6
-#define PB_FromSmartKnob_size                    261
+#define PB_FromSmartKnob_size                    264
 #define PB_Log_size                              258
+#define PB_MotorCalibration_size                 15
+#define PB_PersistentConfiguration_size          47
 #define PB_RequestState_size                     0
-#define PB_SmartKnobConfig_size                  165
-#define PB_SmartKnobState_size                   184
-#define PB_ToSmartknob_size                      174
+#define PB_SmartKnobConfig_size                  184
+#define PB_SmartKnobState_size                   206
+#define PB_StrainCalibration_size                22
+#define PB_ToSmartknob_size                      196
 
 #ifdef __cplusplus
 } /* extern "C" */

+ 4 - 0
firmware/src/proto_helpers.h → firmware/src/serial/proto_helpers.h

@@ -2,14 +2,18 @@
 
 #include "proto_gen/smartknob.pb.h"
 
+#define PROTOBUF_PROTOCOL_VERSION (1)
+
 bool config_eq(PB_SmartKnobConfig& first, PB_SmartKnobConfig& second) {
     return first.detent_strength_unit == second.detent_strength_unit
         && first.endstop_strength_unit == second.endstop_strength_unit
         && first.position == second.position
+        && first.position_nonce == second.position_nonce
         && first.min_position == second.min_position
         && first.max_position == second.max_position
         && first.position_width_radians == second.position_width_radians
         && first.snap_point == second.snap_point
+        && first.sub_position_unit == second.sub_position_unit
         && strcmp(first.text, second.text) == 0
         && first.detent_positions_count == second.detent_positions_count
         && memcmp(first.detent_positions, second.detent_positions, first.detent_positions_count * sizeof(first.detent_positions[0]))

+ 9 - 4
firmware/src/serial/serial_protocol_plaintext.cpp

@@ -40,12 +40,17 @@ void SerialProtocolPlaintext::loop() {
                 demo_config_change_callback_();
             }
         } else if (b == 'C') {
-            motor_task_.runCalibration();
+            motor_calibration_callback_();
+        } else if (b == 'S') {
+            if (strain_calibration_callback_) {
+                strain_calibration_callback_();
+            }
         }
     }
 }
 
-void SerialProtocolPlaintext::init(DemoConfigChangeCallback cb) {
-    demo_config_change_callback_ = cb;
-    stream_.println("SmartKnob starting!\n\nSerial mode: plaintext\nPress 'C' at any time to calibrate.\nPress <Space> to change haptic modes.");
+void SerialProtocolPlaintext::init(DemoConfigChangeCallback demo_config_change_callback, StrainCalibrationCallback strain_calibration_callback) {
+    demo_config_change_callback_ = demo_config_change_callback;
+    strain_calibration_callback_ = strain_calibration_callback;
+    stream_.println("SmartKnob starting!\n\nSerial mode: plaintext\nPress 'C' at any time to calibrate motor/sensor.\nPress 'S' at any time to calibrate strain sensors.\nPress <Space> to change haptic modes.");
 }

+ 6 - 3
firmware/src/serial/serial_protocol_plaintext.h

@@ -2,25 +2,28 @@
 
 #include "../proto_gen/smartknob.pb.h"
 
+#include "interface_callbacks.h"
 #include "motor_task.h"
 #include "serial_protocol.h"
 #include "uart_stream.h"
 
 typedef std::function<void(void)> DemoConfigChangeCallback;
+typedef std::function<void(void)> StrainCalibrationCallback;
 
 class SerialProtocolPlaintext : public SerialProtocol {
     public:
-        SerialProtocolPlaintext(Stream& stream, MotorTask& motor_task) : SerialProtocol(), stream_(stream), motor_task_(motor_task) {}
+        SerialProtocolPlaintext(Stream& stream, MotorCalibrationCallback motor_calibration_callback) : SerialProtocol(), stream_(stream), motor_calibration_callback_(motor_calibration_callback) {}
         ~SerialProtocolPlaintext(){}
         void log(const char* msg) override;
         void loop() override;
         void handleState(const PB_SmartKnobState& state) override;
 
-        void init(DemoConfigChangeCallback cb);
+        void init(DemoConfigChangeCallback demo_config_change_callback, StrainCalibrationCallback strain_calibration_callback);
     
     private:
         Stream& stream_;
-        MotorTask& motor_task_;
+        MotorCalibrationCallback motor_calibration_callback_;
         PB_SmartKnobState latest_state_ = {};
         DemoConfigChangeCallback demo_config_change_callback_;
+        StrainCalibrationCallback strain_calibration_callback_;
 };

+ 15 - 4
firmware/src/serial/serial_protocol_protobuf.cpp

@@ -2,7 +2,7 @@
 
 #include "../proto_gen/smartknob.pb.h"
 
-#include "../proto_helpers.h"
+#include "proto_helpers.h"
 
 #include "crc32.h"
 #include "pb_encode.h"
@@ -14,10 +14,10 @@ static SerialProtocolProtobuf* singleton_for_packet_serial = 0;
 static const uint16_t MIN_STATE_INTERVAL_MILLIS = 5;
 static const uint16_t PERIODIC_STATE_INTERVAL_MILLIS = 5000;
 
-SerialProtocolProtobuf::SerialProtocolProtobuf(Stream& stream, MotorTask& motor_task) :
+SerialProtocolProtobuf::SerialProtocolProtobuf(Stream& stream, ConfigCallback config_callback) :
         SerialProtocol(),
         stream_(stream),
-        motor_task_(motor_task),
+        config_callback_(config_callback),
         packet_serial_() {
     packet_serial_.setStream(&stream);
 
@@ -105,6 +105,13 @@ void SerialProtocolProtobuf::handlePacket(const uint8_t* buffer, size_t size) {
         return;
     }
 
+    if (pb_rx_buffer_.protocol_version != PROTOBUF_PROTOCOL_VERSION) {
+        char buf[200];
+        snprintf(buf, sizeof(buf), "Invalid protocol version. Expected %u, received %u", PROTOBUF_PROTOCOL_VERSION, pb_rx_buffer_.protocol_version);
+        log(buf);
+        return;
+    }
+
     // Always ACK immediately
     ack(pb_rx_buffer_.nonce);
     if (pb_rx_buffer_.nonce == last_nonce_) {
@@ -118,9 +125,12 @@ void SerialProtocolProtobuf::handlePacket(const uint8_t* buffer, size_t size) {
     
     switch (pb_rx_buffer_.which_payload) {
         case PB_ToSmartknob_smartknob_config_tag: {
-            motor_task_.setConfig(pb_rx_buffer_.payload.smartknob_config);
+            config_callback_(pb_rx_buffer_.payload.smartknob_config);
             break;
         }
+        case PB_ToSmartknob_request_state_tag:
+            state_requested_ = true;
+            break;
         default: {
             char buf[200];
             snprintf(buf, sizeof(buf), "Unknown payload type: %d", pb_rx_buffer_.which_payload);
@@ -133,6 +143,7 @@ void SerialProtocolProtobuf::handlePacket(const uint8_t* buffer, size_t size) {
 void SerialProtocolProtobuf::sendPbTxBuffer() {
     // Encode protobuf message to byte buffer
     pb_ostream_t stream = pb_ostream_from_buffer(tx_buffer_, sizeof(tx_buffer_));
+    pb_tx_buffer_.protocol_version = PROTOBUF_PROTOCOL_VERSION;
     if (!pb_encode(&stream, PB_FromSmartKnob_fields, &pb_tx_buffer_)) {
         stream_.println(stream.errmsg);
         stream_.flush();

+ 4 - 3
firmware/src/serial/serial_protocol_protobuf.h

@@ -4,21 +4,22 @@
 
 #include "../proto_gen/smartknob.pb.h"
 
+#include "interface_callbacks.h"
 #include "motor_task.h"
 #include "serial_protocol.h"
 #include "uart_stream.h"
 
 class SerialProtocolProtobuf : public SerialProtocol {
     public:
-        SerialProtocolProtobuf(Stream& stream, MotorTask& motor_task);
-        ~SerialProtocolProtobuf(){}
+        SerialProtocolProtobuf(Stream& stream, ConfigCallback config_callback);
+        ~SerialProtocolProtobuf(){};
         void log(const char* msg) override;
         void loop() override;
         void handleState(const PB_SmartKnobState& state) override;
     
     private:
         Stream& stream_;
-        MotorTask& motor_task_;
+        ConfigCallback config_callback_;
         
         PB_FromSmartKnob pb_tx_buffer_;
         PB_ToSmartknob pb_rx_buffer_;

+ 6 - 0
firmware/src/util.cpp

@@ -0,0 +1,6 @@
+#include "util.h"
+
+float lerp(const float value, const float inMin, const float inMax, const float min, const float max) {
+    // Map the input value from the input range to the output range
+    return ((value - inMin) / (inMax - inMin)) * (max - min) + min;
+}

+ 6 - 0
firmware/src/util.h

@@ -7,3 +7,9 @@ template <typename T> T CLAMP(const T& value, const T& low, const T& high)
 }
 
 #define COUNT_OF(A) (sizeof(A) / sizeof(A[0]))
+
+float lerp(const float value, const float inMin, const float inMax, const float min, const float max);
+
+template <typename T> int sgn(T val) {
+    return (T(0) < val) - (val < T(0));
+}

+ 187 - 12
platformio.ini

@@ -24,29 +24,30 @@ monitor_flags =
 	--eol=CRLF
 	--echo
 	--filter=esp32_exception_decoder
+upload_speed = 921600
 lib_deps =
-    askuric/Simple FOC @ 2.2.0
     infineon/TLV493D-Magnetic-Sensor @ 1.0.3
     bxparks/AceButton @ 1.9.1
+    bakercp/PacketSerial @ 1.4.0
+    nanopb/Nanopb @ 0.4.7   ; Ideally this would reference the nanopb submodule, but that would require
+                            ; everyone to check out submodules to just compile, so we use the library
+                            ; registry for the runtime. The submodule is available for manually updating
+                            ; the pre-compiled (checked in) .pb.h/c files when proto files change, but is
+                            ; otherwise not used during application firmware compilation.
 build_flags =
-  -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG
-  -DMONITOR_SPEED=921600
+    -DMONITOR_SPEED=921600
 
 [env:view]
 extends = base_config
 board = esp32doit-devkit-v1
+board_build.partitions = default_ffat.csv
 lib_deps =
   ${base_config.lib_deps}
+  askuric/Simple FOC @ 2.2.0
   bodmer/TFT_eSPI@2.4.25
   fastled/FastLED @ 3.5.0
   bogde/HX711 @ 0.7.5
   adafruit/Adafruit VEML7700 Library @ 1.1.1
-  bakercp/PacketSerial @ 1.4.0
-  nanopb/Nanopb @ 0.4.6   ; Ideally this would reference the nanopb submodule, but that would require
-                          ; everyone to check out submodules to just compile, so we use the library
-                          ; registry for the runtime. The submodule is available for manually updating
-                          ; the pre-compiled (checked in) .pb.h/c files when proto files change, but is
-                          ; otherwise not used during application firmware compilation.
 
 build_flags =
   ${base_config.build_flags}
@@ -58,13 +59,16 @@ build_flags =
   -DSK_LEDS=1
   ; Number of LEDs
   -DNUM_LEDS=8
-  -DSENSOR_MT6701=1
   ; Strain-gauge press input enabled: 1=enable, 0=disable
   -DSK_STRAIN=1
-  ; Invert direction of angle sensor (motor direction is detected relative to angle sensor as part of the calibration procedure)
-  -DSK_INVERT_ROTATION=1
   ; Ambient light sensor (VEML7700) enabled: 1=enable (display/LEDs match ambient brightness), 0=disable (100% brightness all the time)
   -DSK_ALS=1
+  ; Use MT6701 magnetic encoder
+  -DSENSOR_MT6701=1
+  ; Invert direction of angle sensor (motor direction is detected relative to angle sensor as part of the calibration procedure)
+  -DSK_INVERT_ROTATION=1
+
+  -DMOTOR_WANZHIDA_ONCE_TOP=1
 
   ; Pin configurations
   -DPIN_UH=26
@@ -82,12 +86,16 @@ build_flags =
   -DPIN_MT_CSN=14
   -DPIN_LED_DATA=7
   -DPIN_LCD_BACKLIGHT=19
+  -DPIN_STRAIN_DO=38
+  -DPIN_STRAIN_SCK=2
 
   -DDESCRIPTION_FONT=Roboto_Thin_24
   -DDESCRIPTION_Y_OFFSET=20
   -DVALUE_OFFSET=30
   -DDRAW_ARC=0
 
+  -DSK_BACKLIGHT_BIT_DEPTH=16
+
   ; TFT_eSPI setup
   -DUSER_SETUP_LOADED=1
   -DGC9A01_DRIVER=1
@@ -108,6 +116,173 @@ build_flags =
   ; Reduce loop task stack size (only works on newer IDF Arduino core)
   ; -DARDUINO_LOOP_STACK_SIZE=2048
 
+  -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG
+
+  ; FastLED setup
+  ; Modify the default unusable pin mask to allow GPIO 7 (allowed to use on ESP32-PICO-V3-02)
+  ; Unusable bits: 6, 8, 9, 10, 20
+  ; (0ULL | _FL_BIT(6) | _FL_BIT(8) | _FL_BIT(9) | _FL_BIT(10) | _FL_BIT(20))
+  -DFASTLED_UNUSABLE_PIN_MASK=0x100740LL
+  ; 0~39 except from 24, 28~31 are valid
+  ; (0xFFFFFFFFFFULL & ~(0ULL | _FL_BIT(24) | _FL_BIT(28) | _FL_BIT(29) | _FL_BIT(30) | _FL_BIT(31)))
+  -DSOC_GPIO_VALID_GPIO_MASK=0xFF0EFFFFFF
+  ; GPIO >= 34 are input only
+  ; (SOC_GPIO_VALID_GPIO_MASK & ~(0ULL | _FL_BIT(34) | _FL_BIT(35) | _FL_BIT(36) | _FL_BIT(37) | _FL_BIT(38) | _FL_BIT(39)))
+  -DSOC_GPIO_VALID_OUTPUT_GPIO_MASK=0x30EFFFFFF
+
+
+[env:nanofoc]
+extends = base_config
+platform = espressif32@6.3.1
+board = adafruit_feather_esp32s3
+board_build.partitions = firmware/partitions-4MB-fat.csv
+lib_deps =
+  ${base_config.lib_deps}
+  askuric/Simple FOC@2.3.0
+  bodmer/TFT_eSPI@2.5.0
+
+build_flags =
+  ${base_config.build_flags}
+  ; Display enabled: 1=enable, 0=disable
+  -DSK_DISPLAY=0
+  ; Display orientation: 0=usb bottom, 2=usb top
+  -DSK_DISPLAY_ROTATION=0
+  ; LEDs enabled: 1=enable, 0=disable
+  -DSK_LEDS=0
+  ; Number of LEDs
+  -DNUM_LEDS=8
+  ; Strain-gauge press input enabled: 1=enable, 0=disable
+  -DSK_STRAIN=0
+  ; Ambient light sensor (VEML7700) enabled: 1=enable (display/LEDs match ambient brightness), 0=disable (100% brightness all the time)
+  -DSK_ALS=0
+
+  -DSENSOR_MAQ430=1
+  -DPIN_MAQ_SCK=6
+  -DPIN_MAQ_MISO=7
+  -DPIN_MAQ_MOSI=5
+  -DPIN_MAQ_SS=4
+  ; Invert direction of angle sensor (motor direction is detected relative to angle sensor as part of the calibration procedure)
+  -DSK_INVERT_ROTATION=1
+
+  -DMOTOR_MAD2804=1
+
+  ; Pin configurations
+  -DPIN_UH=21
+  -DPIN_UL=12
+  -DPIN_VH=14
+  -DPIN_VL=10
+  -DPIN_WH=13
+  -DPIN_WL=11
+  -DPIN_BUTTON_NEXT=-1
+  -DPIN_BUTTON_PREV=-1
+  -DPIN_LED_DATA=7
+  -DPIN_LCD_BACKLIGHT=08
+
+  -DPIO_FRAMEWORK_ARDUINO_ENABLE_CDC=1
+  -DUSBCON=1
+  -DARDUINO_USB_CDC_ON_BOOT=1
+  -DARDUINO_USB_MODE=1
+  -DCORE_DEBUG_LEVEL=2
+  -DHSPI_SPEED=100000 ; MA/MAQ Nominal SPI Speed in Mhz (HSPI)
+  -DVSPI_SPEED=400000 ; TFt Nominal SPI Speed in Mhz (VSPI)
+
+  ; Reduce loop task stack size (only works on newer IDF Arduino core)
+  ; -DARDUINO_LOOP_STACK_SIZE=2048
+
+
+[env:brushknight_esp32s3]
+extends = base_config
+platform = espressif32@6.3.1
+board = esp32-s3-devkitc-1
+board_build.partitions = default_ffat.csv
+
+monitor_speed = 115200
+
+lib_deps =
+  ${base_config.lib_deps}
+  bodmer/TFT_eSPI@2.5.0
+  fastled/FastLED @ 3.5.0
+  bogde/HX711 @ 0.7.5
+  adafruit/Adafruit VEML7700 Library @ 1.1.1
+  askuric/Simple FOC@2.3.0
+
+build_flags =
+  ${base_config.build_flags}
+  ; Use physical UART for the serial stream rather than the S3 default USB CDC
+  -DSK_FORCE_UART_STREAM=1
+  -DMONITOR_SPEED=115200
+  ; Display enabled: 1=enable, 0=disable
+  -DSK_DISPLAY=1
+  ; PWM bit resolution (even esp32s3 claims that there are 13 bits, max is 12, after it panics)
+  -DSK_BACKLIGHT_BIT_DEPTH=12
+  ; Display orientation: 0=usb bottom, 2=usb top
+  -DSK_DISPLAY_ROTATION=0
+  ; LEDs enabled: 1=enable, 0=disable
+  -DSK_LEDS=1
+  ; Number of LEDs
+  -DNUM_LEDS=8
+  ; Strain-gauge press input enabled: 1=enable, 0=disable
+  -DSK_STRAIN=1
+  ; Ambient light sensor (VEML7700) enabled: 1=enable (display/LEDs match ambient brightness), 0=disable (100% brightness all the time)
+  -DSK_ALS=0
+  ; Use MT6701 magnetic encoder
+  -DSENSOR_MT6701=1
+  ; Invert direction of angle sensor (motor direction is detected relative to angle sensor as part of the calibration procedure)
+  -DSK_INVERT_ROTATION=1
+
+  -DMOTOR_WANZHIDA_ONCE_TOP=1
+
+  ; Pin configurations
+  ; Motor
+  -DPIN_UH=20
+  -DPIN_UL=19
+  -DPIN_VH=21
+  -DPIN_VL=17
+  -DPIN_WH=12
+  -DPIN_WL=18
+
+  -DPIN_BUTTON_NEXT=-1
+  -DPIN_BUTTON_PREV=-1
+  -DPIN_LED_DATA=7
+  -DPIN_LCD_BACKLIGHT=5
+
+  -DPIN_SDA=15
+  -DPIN_SCL=8
+
+  -DPIN_MT_DATA=37
+  -DPIN_MT_CLOCK=13
+  -DPIN_MT_CSN=14
+
+  -DPIN_STRAIN_DO=38
+  -DPIN_STRAIN_SCK=1
+
+  -DDESCRIPTION_FONT=Roboto_Thin_24
+  -DDESCRIPTION_Y_OFFSET=20
+  -DVALUE_OFFSET=30
+  -DDRAW_ARC=0
+
+  ; TFT_eSPI setup
+  -DUSER_SETUP_LOADED=1
+  -DGC9A01_DRIVER=1
+  -DCGRAM_OFFSET=1
+  -DTFT_WIDTH=240
+  -DTFT_HEIGHT=240
+  -DTFT_MISO=-1 # fake
+  -DTFT_MOSI=3
+  -DTFT_SCLK=4
+  -DTFT_CS=9
+  -DTFT_DC=2
+  -DTFT_RST=6
+  -DTFT_BL=-1
+  -DLOAD_GLCD=1
+  -DLOAD_GFXFF=1
+  -DSPI_FREQUENCY=40000000
+
+  ; Reduce loop task stack size (only works on newer IDF Arduino core)
+  ; -DARDUINO_LOOP_STACK_SIZE=2048
+
+  -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG
+
   ; FastLED setup
   ; Modify the default unusable pin mask to allow GPIO 7 (allowed to use on ESP32-PICO-V3-02)
   ; Unusable bits: 6, 8, 9, 10, 20

+ 187 - 24
proto/smartknob.proto

@@ -5,16 +5,31 @@ import "nanopb.proto";
 package PB;
 
 /*
- * Message FROM the SmartKnob to USB host
+ * Message FROM the SmartKnob to the host
  */
 message FromSmartKnob {
+    uint32 protocol_version = 1 [(nanopb).int_size = IS_8];
     oneof payload {
-        Ack ack = 1;
-        Log log = 2;
-        SmartKnobState smartknob_state = 3;
+        Ack ack = 2;
+        Log log = 3;
+        SmartKnobState smartknob_state = 4;
     }
 }
 
+/*
+ * Message TO the Smartknob from the host
+ */
+ message ToSmartknob {
+    uint32 protocol_version = 1 [(nanopb).int_size = IS_8];
+    uint32 nonce = 2;
+
+    oneof payload {
+        RequestState request_state = 3;
+        SmartKnobConfig smartknob_config = 4;
+    }
+}
+
+/** Lets the host know that a ToSmartknob message was received and should not be retried. */
 message Ack {
     uint32 nonce = 1;
 }
@@ -24,35 +39,183 @@ message Log {
 }
 
 message SmartKnobState {
+    /** Current integer position of the knob. (Detent resolution is at integer positions) */
     int32 current_position = 1;
+
+    /**
+     * Current fractional position. Typically will only range from (-snap_point, snap_point)
+     * since further rotation will result in the integer position changing, but may exceed
+     * those values if snap_point_bias is non-zero, or if the knob is at a bound. When the
+     * knob is at a bound, this value can grow endlessly as the knob is rotated further past
+     * the bound.
+     *
+     * When visualizing sub_position_unit, you will likely want to apply a rubber-band easing
+     * function past the bounds; a sublinear relationship will help suggest that a bound has
+     * been reached.
+     */
     float sub_position_unit = 2;
-    SmartKnobConfig config = 3;
-}
 
-/*
- * Message TO the Smartknob from the USB host
- */
-message ToSmartknob {
-    uint32 nonce = 1;
+    /**
+     * Current SmartKnobConfig in effect at the time of this State snapshot.
+     *
+     * Beware that this config contains position and sub_position_unit values, not to be
+     * confused with the top level current_position and sub_position_unit values in this State
+     * message. The position values in the embedded config message will almost never be useful
+     * to you; you probably want to be reading the top level values from the State message.
+     */
+    SmartKnobConfig config = 3;
 
-    oneof payload {
-        RequestState request_state = 2;
-        SmartKnobConfig smartknob_config = 3;
-    }
+    /**
+     * Value that changes each time the knob is pressed. Does not change when a press is released.
+     *
+     * Why this press state a "nonce" rather than a simple boolean representing the current
+     * "pressed" state? It makes the protocol more robust to dropped/lost State messages; if
+     * the knob was pressed/released quickly and State messages happened to be dropped during
+     * that time, the press would be completely lost. Using a nonce allows the host to recognize
+     * that a press has taken place at some point even if the State was lost during the press
+     * itself. Is this overkill? Probably, let's revisit in future protocol versions.
+     */
+    uint32 press_nonce = 4 [(nanopb).int_size = IS_8];
 }
 
+
 message SmartKnobConfig {
+    /**
+     * Set the integer position.
+     *
+     * Note: in order to make SmartKnobConfig apply idempotently, the current position
+     * will only be set to this value when it changes compared to a previous config (and
+     * NOT compared to the current state!). So by default, if you send a config position
+     * of 5 and the current position is 3, the position may remain at 3 if the config
+     * change to 5 was previously handled. If you need to force a position update, see
+     * position_nonce.
+     */
     int32 position = 1;
-    int32 min_position = 2;
-    int32 max_position = 3;
-    float position_width_radians = 4;
-    float detent_strength_unit = 5;
-    float endstop_strength_unit = 6;
-    float snap_point = 7;
-    string text = 8 [(nanopb).max_length = 50];
-    repeated int32 detent_positions = 9 [(nanopb).max_count = 5];
-    float snap_point_bias = 10;
+
+    /**
+     * Set the fractional position. Typical range: (-snap_point, snap_point).
+     *
+     * Actual range is technically unbounded, but in practice this value will be compared
+     * against snap_point on the next control loop, so any value beyond the snap_point will
+     * generally result in an integer position change (unless position is already at a
+     * limit).
+     *
+     * Note: idempotency implications noted in the documentation for `position` apply here
+     * as well
+     */
+    float sub_position_unit = 2;
+    
+    /**
+     * Position is normally only applied when it changes, but sometimes it's desirable
+     * to reset the position to the same value, so a nonce change can be used to force
+     * the position values to be applied as well.
+     *
+     * NOTE: Must be < 256
+     */
+    uint32 position_nonce = 3 [(nanopb).int_size = IS_8];
+
+    /** Minimum position allowed. */
+    int32 min_position = 4;
+
+    /**
+     * Maximum position allowed.
+     *
+     * If this is the same as min_position, there will only be one allowed position.
+     *
+     * If this is less than min_position, bounds will be disabled.
+     */
+    int32 max_position = 5;
+
+    /** The angular "width" of each position/detent, in radians. */
+    float position_width_radians = 6;
+
+    /**
+     * Strength of detents to apply. Typical range: [0, 1].
+     *
+     * A value of 0 disables detents.
+     *
+     * Values greater than 1 are not recommended and may lead to unstable behavior.
+     */
+    float detent_strength_unit = 7;
+
+    /**
+     * Strength of endstop torque to apply at min/max bounds. Typical range: [0, 1].
+     *
+     * A value of 0 disables endstop torque, but does not make position unbounded, meaning
+     * the knob will not try to return to the valid region. For unbounded rotation, use
+     * min_position and max_position.
+     *
+     * Values greater than 1 are not recommended and may lead to unstable behavior.
+     */
+    float endstop_strength_unit = 8;
+
+    /**
+     * Fractional (sub-position) threshold where the position will increment/decrement.
+     * Typical range: (0.5, 1.5).
+     *
+     * This defines how hysteresis is applied to positions, which is why values >
+     */
+    float snap_point = 9;
+
+    /**
+     * Arbitrary 50-byte string representing this "config". This can be used to identify major
+     * config/mode changes. The value will be echoed back to the host via a future State's
+     * embedded config field so the host can use this value to determine the mode that was
+     * in effect at the time of the State snapshot instead of having to infer it from the
+     * other config fields.
+     */
+    string text = 10 [(nanopb).max_length = 50];
+
+    /**
+     * For a "magnetic" detent mode - where not all positions should have detents - this
+     * specifies which positions (up to 5) have detents enabled. The knob will feel like it
+     * is "magnetically" attracted to those positions, and will rotate smoothy past all
+     * other positions.
+     *
+     * If you want to have more than 5 magnetic detent positions, you will need to dynamically
+     * update this list as the knob is rotated. A recommended approach is to always send the
+     * _nearest_ 5 detent positions, and send a new Config message whenever the list of
+     * positions nearest the current position (as reported via State messages) changes.
+     *
+     * This approach enables effectively unbounded detent positions while keeping Config
+     * bounded in size, and is resilient against tightly-packed detents with fast rotation
+     * since multiple detent positions can be sent in advance; a full round-trip Config-State
+     * isn't needed between each detent in order to keep up.
+     */
+    repeated int32 detent_positions = 11 [(nanopb).max_count = 5];
+
+    /**
+     * Advanced feature for shifting the defined snap_point away from the center (position 0)
+     * for implementing asymmetric detents. Typical value: 0 (symmetric detent force).
+     *
+     * This can be used to create detents that will hold the position when carefully released,
+     * but can be easily disturbed to return "home" towards position 0.
+     */
+    float snap_point_bias = 12;
+
+    /**
+     * Hue (0-255) for all 8 ring LEDs, if supported. Note: this will likely be replaced
+     * with more configurability in a future protocol version.
+     */
+    int32 led_hue = 13 [(nanopb).int_size = IS_16];
 }
 
 message RequestState {}
 
+message PersistentConfiguration {
+    uint32 version = 1;
+    MotorCalibration motor = 2;
+    StrainCalibration strain = 3;
+}
+
+message MotorCalibration {
+    bool calibrated = 1;
+    float zero_electrical_offset = 2;
+    bool direction_cw = 3;
+    uint32 pole_pairs = 4;
+}
+
+message StrainCalibration {
+    int32 idle_value = 1;
+    int32 press_delta = 2; 
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 302 - 379
software/js/package-lock.json


+ 10 - 7
software/js/package.json

@@ -9,20 +9,23 @@
   },
   "scripts": {
     "build": "npm run build --workspaces --if-present",
-    "demo": "concurrently \"npm -w demo-frontend start\" \"npm -w demo-backend start\"",
-    "example": "npm -w example run main"
+    "example-cli": "npm -w example-cli run main",
+    "example-webserial-basic": "npm -w example-webserial-basic run start",
+    "example-webserial-timeline": "npm -w example-webserial-timeline run start"
   },
   "author": "",
   "license": "Apache-2.0",
+  "//": "NOTE: Workspaces listed in dependency order; intentionally not alphabetical!",
   "workspaces": [
     "packages/smartknobjs-proto",
-    "packages/smartknobjs",
-    "packages/demo-backend",
-    "packages/demo-frontend",
-    "packages/example"
+    "packages/smartknobjs-core",
+    "packages/smartknobjs-node",
+    "packages/smartknobjs-webserial",
+    "packages/example-cli",
+    "packages/example-webserial-basic",
+    "packages/example-webserial-timeline"
   ],
   "devDependencies": {
-    "concurrently": "^7.6.0",
     "eslint-config-prettier": "^8.5.0"
   }
 }

+ 0 - 32
software/js/packages/demo-backend/package.json

@@ -1,32 +0,0 @@
-{
-  "name": "demo-backend",
-  "version": "0.1.0",
-  "description": "",
-  "main": "dist/index.js",
-  "types": "dist/index.d.ts",
-  "scripts": {
-    "build": "tsc",
-    "format": "prettier --write \"**/*.+(js|ts|json)\"",
-    "lint": "eslint --ext .js,.ts .",
-    "start": "PORT=3001 ts-node src/index.ts"
-  },
-  "author": "",
-  "license": "Apache-2.0",
-  "dependencies": {
-    "serialport": "^9.2.4",
-    "smartknobjs": "^0.1.0",
-    "socket.io": "^4.5.4"
-  },
-  "devDependencies": {
-    "@types/express": "^4.17.14",
-    "@types/node": "^18.11.10",
-    "@types/serialport": "^8.0.2",
-    "@typescript-eslint/eslint-plugin": "^5.40.1",
-    "@typescript-eslint/parser": "^5.40.1",
-    "eslint": "^8.25.0",
-    "nodemon": "^2.0.20",
-    "prettier": "^2.4.1",
-    "ts-node": "^10.2.1",
-    "typescript": "^4.9.3"
-  }
-}

+ 0 - 75
software/js/packages/demo-backend/src/index.ts

@@ -1,75 +0,0 @@
-import SerialPort = require('serialport')
-import {SmartKnob} from 'smartknobjs'
-import {PB} from 'smartknobjs-proto'
-
-import {Server, Socket} from 'socket.io'
-
-const io = new Server(parseInt(process.env.PORT ?? '3001'))
-
-const start = async () => {
-    const ports = await SerialPort.list()
-
-    const matchingPorts = ports.filter((portInfo) => {
-        // Implement a check for your device's vendor+product+serial
-        // (this is more robust than the alternative of just hardcoding a "path" like "/dev/ttyUSB0")
-        return (
-            portInfo.vendorId?.toLowerCase() === '1a86'.toLowerCase() &&
-            portInfo.productId?.toLowerCase() === '7523'.toLowerCase()
-            // && portInfo.serialNumber === 'DEADBEEF'
-        )
-    })
-
-    if (matchingPorts.length < 1) {
-        console.error(`No smartknob usb serial port found! ${JSON.stringify(ports, undefined, 4)}`)
-        return
-    } else if (matchingPorts.length > 1) {
-        console.error(`Multiple smartknob usb serial ports found: ${JSON.stringify(matchingPorts, undefined, 4)}`)
-        return
-    }
-
-    const portInfo = matchingPorts[0]
-    console.info('Connecting to ', portInfo)
-
-    let lastLoggedState: PB.ISmartKnobState | undefined
-    const smartknob = new SmartKnob(portInfo.path, (message: PB.FromSmartKnob) => {
-        if (message.payload === 'log' && message.log) {
-            console.log('LOG', message.log.msg)
-        } else if (message.payload === 'smartknobState' && message.smartknobState) {
-            const state = PB.SmartKnobState.toObject(message.smartknobState as PB.SmartKnobState, {defaults: true})
-            io.emit('state', {pb: message.smartknobState})
-            if (
-                message.smartknobState.currentPosition !== lastLoggedState?.currentPosition ||
-                Math.abs((message.smartknobState.subPositionUnit ?? 0) - (lastLoggedState?.subPositionUnit ?? 0)) > 1
-            ) {
-                console.log(`State:\n${JSON.stringify(state, undefined, 4)}`)
-                lastLoggedState = message.smartknobState
-            }
-        }
-    })
-    smartknob.sendConfig(
-        PB.SmartKnobConfig.create({
-            detentStrengthUnit: 1,
-            endstopStrengthUnit: 1,
-            position: 0,
-            minPosition: -5,
-            maxPosition: 5,
-            positionWidthRadians: (10 * Math.PI) / 180,
-            snapPoint: 1.1,
-            text: 'From TS!',
-        }),
-    )
-
-    let currentSocket: Socket | null = null
-    io.on('connection', (socket) => {
-        if (currentSocket !== null) {
-            currentSocket.disconnect(true)
-        }
-        currentSocket = socket
-        socket.on('set_config', (config) => {
-            console.log(config)
-            smartknob.sendConfig(config)
-        })
-    })
-}
-
-start()

+ 0 - 43
software/js/packages/demo-frontend/public/index.html

@@ -1,43 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8" />
-    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
-    <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <meta name="theme-color" content="#000000" />
-    <meta
-      name="description"
-      content="Web site created using create-react-app"
-    />
-    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
-    <!--
-      manifest.json provides metadata used when your web app is installed on a
-      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-    -->
-    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
-    <!--
-      Notice the use of %PUBLIC_URL% in the tags above.
-      It will be replaced with the URL of the `public` folder during the build.
-      Only files inside the `public` folder can be referenced from the HTML.
-
-      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
-      work correctly both with client-side routing and a non-root public URL.
-      Learn how to configure a non-root public URL by running `npm run build`.
-    -->
-    <title>React App</title>
-  </head>
-  <body>
-    <noscript>You need to enable JavaScript to run this app.</noscript>
-    <div id="root"></div>
-    <!--
-      This HTML file is a template.
-      If you open it directly in the browser, you will see an empty page.
-
-      You can add webfonts, meta tags, or analytics to this file.
-      The build step will place the bundled scripts into the <body> tag.
-
-      To begin the development, run `npm start` or `yarn start`.
-      To create a production bundle, use `npm run build` or `yarn build`.
-    -->
-  </body>
-</html>

+ 0 - 25
software/js/packages/demo-frontend/public/manifest.json

@@ -1,25 +0,0 @@
-{
-  "short_name": "React App",
-  "name": "Create React App Sample",
-  "icons": [
-    {
-      "src": "favicon.ico",
-      "sizes": "64x64 32x32 24x24 16x16",
-      "type": "image/x-icon"
-    },
-    {
-      "src": "logo192.png",
-      "type": "image/png",
-      "sizes": "192x192"
-    },
-    {
-      "src": "logo512.png",
-      "type": "image/png",
-      "sizes": "512x512"
-    }
-  ],
-  "start_url": ".",
-  "display": "standalone",
-  "theme_color": "#000000",
-  "background_color": "#ffffff"
-}

+ 0 - 407
software/js/packages/demo-frontend/src/App.tsx

@@ -1,407 +0,0 @@
-import React, {useEffect, useMemo, useRef, useState} from 'react'
-import io from 'socket.io-client'
-import Typography from '@mui/material/Typography'
-import Container from '@mui/material/Container'
-import ToggleButton from '@mui/material/ToggleButton'
-import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
-import {PB} from 'smartknobjs-proto'
-import {VideoInfo} from './types'
-import {Card, CardContent} from '@mui/material'
-import {exhaustiveCheck, findNClosest, INT32_MIN, lerp, NoUndefinedField} from './util'
-import {groupBy, parseInt} from 'lodash'
-
-const socket = io()
-
-const MIN_ZOOM = 0.01
-const MAX_ZOOM = 60
-
-const PIXELS_PER_POSITION = 10
-
-enum Mode {
-    Scroll = 'Scroll',
-    Frames = 'Frames',
-    Speed = 'Speed',
-}
-
-type State = {
-    mode: Mode
-    playbackSpeed: number
-    currentFrame: number
-    zoomTimelinePixelsPerFrame: number
-}
-
-export type AppProps = {
-    info: VideoInfo
-}
-export const App: React.FC<AppProps> = ({info}) => {
-    const [isConnected, setIsConnected] = useState(socket.connected)
-    const [state, setState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
-        PB.SmartKnobState.toObject(PB.SmartKnobState.create({config: PB.SmartKnobConfig.create()}), {
-            defaults: true,
-        }) as NoUndefinedField<PB.ISmartKnobState>,
-    )
-    const [derivedState, setDerivedState] = useState<State>({
-        mode: Mode.Scroll,
-        playbackSpeed: 0,
-        currentFrame: 0,
-        zoomTimelinePixelsPerFrame: 0.1,
-    })
-
-    useMemo(() => {
-        setDerivedState((cur) => {
-            const modeText = state.config.text
-            if (modeText === Mode.Scroll) {
-                const rawFrame = Math.trunc(
-                    ((state.currentPosition + state.subPositionUnit) * PIXELS_PER_POSITION) /
-                        cur.zoomTimelinePixelsPerFrame,
-                )
-                return {
-                    mode: Mode.Scroll,
-                    playbackSpeed: 0,
-                    currentFrame: Math.min(Math.max(rawFrame, 0), info.totalFrames - 1),
-                    zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
-                }
-            } else if (modeText === Mode.Frames) {
-                return {
-                    mode: Mode.Frames,
-                    playbackSpeed: 0,
-                    currentFrame: state.currentPosition ?? 0,
-                    zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
-                }
-            } else if (modeText === Mode.Speed) {
-                const normalizedWholeValue = state.currentPosition
-                const normalizedFractional =
-                    Math.sign(state.subPositionUnit) *
-                    lerp(state.subPositionUnit * Math.sign(state.subPositionUnit), 0.1, 0.9, 0, 1)
-                const normalized = normalizedWholeValue + normalizedFractional
-                const speed = Math.sign(normalized) * Math.pow(2, Math.abs(normalized) - 1)
-                return {
-                    mode: Mode.Speed,
-                    playbackSpeed: speed,
-                    currentFrame: cur.currentFrame,
-                    zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
-                }
-            }
-            return cur
-        })
-    }, [state.config.text, state.currentPosition, state.subPositionUnit])
-
-    const totalPositions = Math.ceil((info.totalFrames * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
-    const detentPositions = useMemo(() => {
-        // Always include the first and last positions at detents
-        const positionsToFrames = groupBy([0, ...info.boundaryFrames, info.totalFrames - 1], (frame) =>
-            Math.round((frame * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION),
-        )
-        console.log(JSON.stringify(positionsToFrames))
-        return positionsToFrames
-    }, [info.boundaryFrames, totalPositions, derivedState.zoomTimelinePixelsPerFrame])
-
-    // Continuous config updates for scrolling, to update detent positions
-    useMemo(() => {
-        if (derivedState.mode === Mode.Scroll) {
-            const config = PB.SmartKnobConfig.create({
-                position: INT32_MIN,
-                minPosition: 0,
-                maxPosition: totalPositions - 1,
-                positionWidthRadians: (8 * Math.PI) / 180,
-                detentStrengthUnit: 2.5,
-                endstopStrengthUnit: 1,
-                snapPoint: 0.7,
-                text: Mode.Scroll,
-                detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), state.currentPosition, 5),
-                snapPointBias: 0,
-            })
-            socket.emit('set_config', config)
-        }
-    }, [derivedState.mode, derivedState.zoomTimelinePixelsPerFrame, detentPositions, state.currentPosition])
-
-    // For one-off config pushes, e.g. mode changes
-    const pushConfig = (state: State) => {
-        let config: PB.SmartKnobConfig
-        if (state.mode === Mode.Scroll) {
-            const position = Math.trunc((state.currentFrame * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
-            config = PB.SmartKnobConfig.create({
-                position,
-                minPosition: 0,
-                maxPosition: Math.trunc(
-                    ((info.totalFrames - 1) * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
-                ),
-                positionWidthRadians: (8 * Math.PI) / 180,
-                detentStrengthUnit: 2.5,
-                endstopStrengthUnit: 1,
-                snapPoint: 0.7,
-                text: Mode.Scroll,
-                detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), position, 5),
-                snapPointBias: 0,
-            })
-        } else if (state.mode === Mode.Frames) {
-            config = PB.SmartKnobConfig.create({
-                position: state.currentFrame,
-                minPosition: 0,
-                maxPosition: info.totalFrames - 1,
-                positionWidthRadians: (1.5 * Math.PI) / 180,
-                detentStrengthUnit: 1,
-                endstopStrengthUnit: 1,
-                snapPoint: 1.1,
-                text: Mode.Frames,
-                detentPositions: [],
-                snapPointBias: 0,
-            })
-        } else if (state.mode === Mode.Speed) {
-            config = PB.SmartKnobConfig.create({
-                position: state.playbackSpeed === 0 ? 0 : INT32_MIN,
-                minPosition: state.currentFrame === 0 ? 0 : -6,
-                maxPosition: state.currentFrame === info.totalFrames - 1 ? 0 : 6,
-                positionWidthRadians: (60 * Math.PI) / 180,
-                detentStrengthUnit: 1,
-                endstopStrengthUnit: 1,
-                snapPoint: 0.55,
-                text: Mode.Speed,
-                detentPositions: [],
-                snapPointBias: 0.4,
-            })
-        } else {
-            throw exhaustiveCheck(state.mode)
-        }
-        socket.emit('set_config', config)
-    }
-
-    const setCurrentFrame = (fn: (oldFrame: number) => number) => {
-        setDerivedState((cur) => {
-            const newState = {...cur}
-            if (cur.mode === Mode.Speed) {
-                newState.currentFrame = fn(cur.currentFrame)
-            }
-            return newState
-        })
-    }
-
-    // Timer for speed-based playback
-    useEffect(() => {
-        const refreshInterval = 20
-        const fps = info.frameRate * derivedState.playbackSpeed
-        if (derivedState.mode === Mode.Speed && fps !== 0) {
-            const timer = setInterval(() => {
-                setCurrentFrame((oldFrame) => {
-                    const newFrame = oldFrame + (fps * refreshInterval) / 1000
-
-                    const oldFrameTrunc = Math.trunc(oldFrame)
-                    const newFrameTrunc = Math.trunc(newFrame)
-
-                    if (newFrame < 0 || newFrame >= info.totalFrames) {
-                        const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
-                        if (oldFrame !== clampedNewFrame) {
-                            // If we've hit a boundary, push a config to set the bounds
-                            pushConfig({
-                                mode: Mode.Speed,
-                                playbackSpeed: 0,
-                                currentFrame: Math.trunc(clampedNewFrame),
-                                zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
-                            })
-                        }
-                        return clampedNewFrame
-                    } else {
-                        if (
-                            (oldFrameTrunc === 0 && newFrameTrunc > 0) ||
-                            (oldFrameTrunc === info.totalFrames - 1 && newFrameTrunc < info.totalFrames - 1)
-                        ) {
-                            // If we've left a boundary condition, push a config to reset the bounds
-                            pushConfig({
-                                mode: derivedState.mode,
-                                playbackSpeed: 0,
-                                currentFrame: newFrameTrunc,
-                                zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
-                            })
-                        }
-                        return newFrame
-                    }
-                })
-            }, refreshInterval)
-            return () => clearInterval(timer)
-        }
-    }, [derivedState.mode, derivedState.playbackSpeed, info.totalFrames, info.frameRate])
-
-    // Socket.io subscription
-    useEffect(() => {
-        socket.on('connect', () => {
-            setIsConnected(true)
-            pushConfig(derivedState)
-        })
-
-        socket.on('disconnect', () => {
-            setIsConnected(false)
-        })
-
-        socket.on('state', (input: {pb: PB.SmartKnobState}) => {
-            const {pb: state} = input
-            const stateObj = PB.SmartKnobState.toObject(state, {
-                defaults: true,
-            }) as NoUndefinedField<PB.ISmartKnobState>
-            setState(stateObj)
-        })
-        return () => {
-            socket.off('connect')
-            socket.off('disconnect')
-            socket.off('state')
-        }
-    }, [])
-    return (
-        <>
-            <Container component="main" maxWidth="md">
-                <Card>
-                    <CardContent>
-                        <Typography component="h1" variant="h5">
-                            Video Playback Control Demo
-                        </Typography>
-                        {isConnected || (
-                            <Typography component="h6" variant="h6">
-                                [Not connected]
-                            </Typography>
-                        )}
-                        <ToggleButtonGroup
-                            color="primary"
-                            value={derivedState.mode}
-                            exclusive
-                            onChange={(e, value: Mode | null) => {
-                                if (value === null) {
-                                    return
-                                }
-                                pushConfig({
-                                    ...derivedState,
-                                    mode: value,
-                                })
-                            }}
-                            aria-label="Mode"
-                        >
-                            {Object.keys(Mode).map((mode) => (
-                                <ToggleButton value={mode} key={mode}>
-                                    {mode}
-                                </ToggleButton>
-                            ))}
-                        </ToggleButtonGroup>
-                        <Typography>
-                            Frame {Math.trunc(derivedState.currentFrame)} / {info.totalFrames - 1}
-                            <br />
-                            Speed {Math.trunc(derivedState.playbackSpeed * 10) / 10}
-                        </Typography>
-                    </CardContent>
-                </Card>
-                <Timeline
-                    info={info}
-                    currentFrame={derivedState.currentFrame}
-                    zoomTimelinePixelsPerFrame={derivedState.zoomTimelinePixelsPerFrame}
-                    adjustZoom={(factor) => {
-                        setDerivedState((cur) => {
-                            const newZoom = Math.min(
-                                Math.max(cur.zoomTimelinePixelsPerFrame * factor, MIN_ZOOM),
-                                MAX_ZOOM,
-                            )
-                            console.log(factor, newZoom)
-                            return {
-                                ...cur,
-                                zoomTimelinePixelsPerFrame: newZoom,
-                            }
-                        })
-                    }}
-                />
-                <Card>
-                    <CardContent>
-                        <div>{JSON.stringify(detentPositions)}</div>
-                    </CardContent>
-                </Card>
-            </Container>
-        </>
-    )
-}
-
-export type TimelineProps = {
-    info: VideoInfo
-    currentFrame: number
-    zoomTimelinePixelsPerFrame: number
-    adjustZoom: (factor: number) => void
-}
-export const Timeline: React.FC<TimelineProps> = ({info, currentFrame, zoomTimelinePixelsPerFrame, adjustZoom}) => {
-    const gradients = [
-        'linear-gradient( 135deg, #FDEB71 10%, #F8D800 100%)',
-        'linear-gradient( 135deg, #ABDCFF 10%, #0396FF 100%)',
-        'linear-gradient( 135deg, #FEB692 10%, #EA5455 100%)',
-        'linear-gradient( 135deg, #CE9FFC 10%, #7367F0 100%)',
-        'linear-gradient( 135deg, #90F7EC 10%, #32CCBC 100%)',
-    ]
-
-    const timelineRef = useRef<HTMLDivElement>(null)
-    const cursorRef = useRef<HTMLDivElement>(null)
-
-    useEffect(() => {
-        const handleWheel = (event: HTMLElementEventMap['wheel']) => {
-            const delta = event.deltaY
-            if (delta) {
-                event.preventDefault()
-                adjustZoom(1 - delta / 500)
-            }
-        }
-
-        timelineRef.current?.addEventListener('wheel', handleWheel)
-        return () => {
-            timelineRef.current?.removeEventListener('wheel', handleWheel)
-        }
-    }, [])
-
-    useEffect(() => {
-        cursorRef.current?.scrollIntoView()
-    }, [currentFrame, zoomTimelinePixelsPerFrame])
-    return (
-        <div
-            className="timeline-container"
-            ref={timelineRef}
-            style={{
-                width: '100%',
-                margin: '10px auto',
-                overflowX: 'scroll',
-            }}
-        >
-            <div
-                className="timeline"
-                style={{
-                    position: 'relative',
-                    display: 'inline-block',
-                    height: '80px',
-                    width: `${zoomTimelinePixelsPerFrame * info.totalFrames}px`,
-                    backgroundColor: '#dde',
-                }}
-            >
-                {[...info.boundaryFrames, info.totalFrames].map((f, i, a) => {
-                    const lengthFrames = f - (a[i - 1] ?? 0)
-                    const widthPixels = zoomTimelinePixelsPerFrame * lengthFrames
-                    return (
-                        <div
-                            key={`clip-${f}`}
-                            className="video-clip"
-                            style={{
-                                position: 'relative',
-                                display: 'inline-block',
-                                top: '10px',
-                                height: '60px',
-                                width: `${widthPixels}px`,
-                                backgroundImage: gradients[i % gradients.length],
-                            }}
-                        ></div>
-                    )
-                })}
-                <div
-                    className="playback-cursor"
-                    ref={cursorRef}
-                    style={{
-                        position: 'absolute',
-                        display: 'inline-block',
-                        left: `${zoomTimelinePixelsPerFrame * Math.trunc(currentFrame)}px`,
-                        width: `${Math.max(zoomTimelinePixelsPerFrame, 1)}px`,
-                        height: '100%',
-                        backgroundColor: 'rgba(255, 0, 0, 0.4)',
-                        borderLeft: '1px solid red',
-                    }}
-                ></div>
-            </div>
-        </div>
-    )
-}

+ 0 - 15
software/js/packages/demo-frontend/src/reportWebVitals.ts

@@ -1,15 +0,0 @@
-import { ReportHandler } from 'web-vitals';
-
-const reportWebVitals = (onPerfEntry?: ReportHandler) => {
-  if (onPerfEntry && onPerfEntry instanceof Function) {
-    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
-      getCLS(onPerfEntry);
-      getFID(onPerfEntry);
-      getFCP(onPerfEntry);
-      getLCP(onPerfEntry);
-      getTTFB(onPerfEntry);
-    });
-  }
-};
-
-export default reportWebVitals;

+ 0 - 26
software/js/packages/demo-frontend/tsconfig.json

@@ -1,26 +0,0 @@
-{
-  "compilerOptions": {
-    "target": "es5",
-    "lib": [
-      "dom",
-      "dom.iterable",
-      "esnext"
-    ],
-    "allowJs": true,
-    "skipLibCheck": true,
-    "esModuleInterop": true,
-    "allowSyntheticDefaultImports": true,
-    "strict": true,
-    "forceConsistentCasingInFileNames": true,
-    "noFallthroughCasesInSwitch": true,
-    "module": "esnext",
-    "moduleResolution": "node",
-    "resolveJsonModule": true,
-    "isolatedModules": true,
-    "noEmit": true,
-    "jsx": "react-jsx"
-  },
-  "include": [
-    "src"
-  ]
-}

+ 0 - 0
software/js/packages/demo-backend/.eslintrc → software/js/packages/example-cli/.eslintrc


+ 0 - 0
software/js/packages/demo-backend/.prettierrc → software/js/packages/example-cli/.prettierrc


+ 4 - 3
software/js/packages/example/package.json → software/js/packages/example-cli/package.json

@@ -1,7 +1,7 @@
 {
-    "name": "example",
+    "name": "example-cli",
     "version": "0.1.0",
-    "description": "SmartKnob Interface Library",
+    "description": "SmartKnob Interface Example",
     "main": "dist/index.js",
     "types": "dist/index.d.ts",
     "scripts": {
@@ -14,7 +14,8 @@
     "license": "Apache-2.0",
     "dependencies": {
         "serialport": "^9.2.4",
-        "smartknobjs": "^0.1.0"
+        "smartknobjs-proto": "^1.0.0",
+        "smartknobjs-webserial": "^1.0.0"
     },
     "devDependencies": {
         "@types/serialport": "^8.0.2",

+ 16 - 10
software/js/packages/example/src/index.ts → software/js/packages/example-cli/src/index.ts

@@ -1,17 +1,14 @@
 import SerialPort = require('serialport')
-import {SmartKnob} from 'smartknobjs'
+import {SmartKnobNode} from 'smartknobjs-node'
 import {PB} from 'smartknobjs-proto'
 
 const main = async () => {
     const ports = await SerialPort.list()
 
     const matchingPorts = ports.filter((portInfo) => {
-        // Implement a check for your device's vendor+product+serial
-        // (this is more robust than the alternative of just hardcoding a "path" like "/dev/ttyUSB0")
-        return (
-            portInfo.vendorId?.toLowerCase() === '1a86'.toLowerCase() &&
-            portInfo.productId?.toLowerCase() === '7523'.toLowerCase()
-            // && portInfo.serialNumber === 'DEADBEEF'
+        return SmartKnobNode.USB_DEVICE_FILTERS.some(
+            (f) =>
+                f.usbVendorId.toString(16) === portInfo.vendorId && f.usbProductId.toString(16) === portInfo.productId,
         )
     })
 
@@ -19,20 +16,27 @@ const main = async () => {
         console.error(`No smartknob usb serial port found! ${JSON.stringify(ports, undefined, 4)}`)
         return
     } else if (matchingPorts.length > 1) {
-        console.error(`Multiple smartknob usb serial ports found: ${JSON.stringify(matchingPorts, undefined, 4)}`)
+        console.error(
+            `Multiple possible smartknob usb serial ports found: ${JSON.stringify(matchingPorts, undefined, 4)}`,
+        )
         return
     }
 
     const portInfo = matchingPorts[0]
 
     let lastLoggedState: PB.ISmartKnobState | undefined
-    const smartknob = new SmartKnob(portInfo.path, (message: PB.FromSmartKnob) => {
+    const smartknob = new SmartKnobNode(portInfo.path, (message: PB.FromSmartKnob) => {
         if (message.payload === 'log' && message.log) {
             console.log('LOG', message.log.msg)
         } else if (message.payload === 'smartknobState' && message.smartknobState) {
+            // Only log if it's a significant change (major position change, or at least 5 degrees)
+            const radianChange =
+                (message.smartknobState.subPositionUnit ?? 0) *
+                    (message.smartknobState.config?.positionWidthRadians ?? 0) -
+                (lastLoggedState?.subPositionUnit ?? 0) * (lastLoggedState?.config?.positionWidthRadians ?? 0)
             if (
                 message.smartknobState.currentPosition !== lastLoggedState?.currentPosition ||
-                Math.abs((message.smartknobState.subPositionUnit ?? 0) - (lastLoggedState?.subPositionUnit ?? 0)) > 1
+                (Math.abs(radianChange) * 180) / Math.PI > 5
             ) {
                 console.log(
                     `State:\n${JSON.stringify(
@@ -50,6 +54,8 @@ const main = async () => {
             detentStrengthUnit: 1,
             endstopStrengthUnit: 1,
             position: 0,
+            subPositionUnit: 0,
+            positionNonce: Math.floor(Math.random() * 255), // Pick a random nonce to force a position reset on start
             minPosition: 0,
             maxPosition: 4,
             positionWidthRadians: (10 * Math.PI) / 180,

+ 0 - 0
software/js/packages/demo-backend/tsconfig.json → software/js/packages/example-cli/tsconfig.json


+ 0 - 0
software/js/packages/demo-frontend/.eslintrc → software/js/packages/example-webserial-basic/.eslintrc


+ 0 - 0
software/js/packages/demo-frontend/.gitignore → software/js/packages/example-webserial-basic/.gitignore


+ 0 - 0
software/js/packages/demo-frontend/.prettierrc → software/js/packages/example-webserial-basic/.prettierrc


+ 0 - 0
software/js/packages/demo-frontend/README.md → software/js/packages/example-webserial-basic/README.md


+ 5 - 4
software/js/packages/demo-frontend/package.json → software/js/packages/example-webserial-basic/package.json

@@ -1,5 +1,5 @@
 {
-    "name": "demo-frontend",
+    "name": "example-webserial-basic",
     "version": "0.1.0",
     "private": true,
     "dependencies": {
@@ -18,8 +18,8 @@
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
         "react-scripts": "^5.0.1",
-        "smartknobjs-proto": "^0.1.1",
-        "socket.io-client": "^4.5.4",
+        "smartknobjs-proto": "^1.0.0",
+        "smartknobjs-webserial": "^1.0.0",
         "typescript": "^4.9.3",
         "web-vitals": "^2.1.4"
     },
@@ -49,6 +49,7 @@
     },
     "proxy": "http://localhost:3001",
     "devDependencies": {
-        "@types/lodash": "^4.14.191"
+        "@types/lodash": "^4.14.191",
+        "@types/w3c-web-serial": "^1.0.3"
     }
 }

+ 0 - 0
software/js/packages/demo-frontend/public/favicon.ico → software/js/packages/example-webserial-basic/public/favicon.ico


+ 15 - 0
software/js/packages/example-webserial-basic/public/index.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8" />
+        <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+        <meta name="viewport" content="width=device-width, initial-scale=1" />
+        <meta name="theme-color" content="#000000" />
+        <meta name="description" content="Interactive SmartKnob demo using Web Serial" />
+        <title>SmartKnob Demo</title>
+    </head>
+    <body>
+        <noscript>You need to enable JavaScript to run this app.</noscript>
+        <div id="root"></div>
+    </body>
+</html>

+ 0 - 0
software/js/packages/demo-frontend/public/robots.txt → software/js/packages/example-webserial-basic/public/robots.txt


+ 303 - 0
software/js/packages/example-webserial-basic/src/App.tsx

@@ -0,0 +1,303 @@
+import React, {useEffect, useState} from 'react'
+import Typography from '@mui/material/Typography'
+import Container from '@mui/material/Container'
+import {PB} from 'smartknobjs-proto'
+import {Box, Button, CardActions, Paper, TextField} from '@mui/material'
+import {NoUndefinedField} from './util'
+import {SmartKnobWebSerial} from 'smartknobjs-webserial'
+
+type Config = NoUndefinedField<PB.ISmartKnobConfig>
+
+const defaultConfig: Config = {
+    position: 0,
+    subPositionUnit: 0,
+    positionNonce: Math.floor(Math.random() * 255),
+    minPosition: 0,
+    maxPosition: 20,
+    positionWidthRadians: (15 * Math.PI) / 180,
+    detentStrengthUnit: 0.5,
+    endstopStrengthUnit: 1,
+    snapPoint: 0.7,
+    text: 'Hello from\nweb serial!',
+    detentPositions: [],
+    snapPointBias: 0,
+    ledHue: 0,
+}
+
+export type AppProps = object
+export const App: React.FC<AppProps> = () => {
+    const [smartKnob, setSmartKnob] = useState<SmartKnobWebSerial | null>(null)
+    const [smartKnobState, setSmartKnobState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
+        PB.SmartKnobState.toObject(PB.SmartKnobState.create({config: PB.SmartKnobConfig.create()}), {
+            defaults: true,
+        }) as NoUndefinedField<PB.ISmartKnobState>,
+    )
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const [smartKnobConfig, setSmartKnobConfig] = useState<Config>(defaultConfig)
+    useEffect(() => {
+        console.log('send config', smartKnobConfig)
+        smartKnob?.sendConfig(PB.SmartKnobConfig.create(smartKnobConfig))
+    }, [
+        smartKnob,
+        smartKnobConfig.position,
+        smartKnobConfig.subPositionUnit,
+        smartKnobConfig.positionNonce,
+        smartKnobConfig.minPosition,
+        smartKnobConfig.maxPosition,
+        smartKnobConfig.positionWidthRadians,
+        smartKnobConfig.detentStrengthUnit,
+        smartKnobConfig.endstopStrengthUnit,
+        smartKnobConfig.snapPoint,
+        smartKnobConfig.text,
+        smartKnobConfig.detentPositions,
+        smartKnobConfig.snapPointBias,
+    ])
+    const [pendingSmartKnobConfig, setPendingSmartKnobConfig] = useState<{[P in keyof Config]: string}>(() => {
+        return Object.fromEntries(Object.entries(defaultConfig).map(([key, value]) => [key, String(value)])) as {
+            [P in keyof Config]: string
+        }
+    })
+
+    const connectToSerial = async () => {
+        try {
+            if (navigator.serial) {
+                const serialPort = await navigator.serial.requestPort({
+                    filters: SmartKnobWebSerial.USB_DEVICE_FILTERS,
+                })
+                serialPort.addEventListener('disconnect', () => {
+                    setSmartKnob(null)
+                })
+                const smartKnob = new SmartKnobWebSerial(serialPort, (message) => {
+                    if (message.payload === 'smartknobState' && message.smartknobState !== null) {
+                        const state = PB.SmartKnobState.create(message.smartknobState)
+                        const stateObj = PB.SmartKnobState.toObject(state, {
+                            defaults: true,
+                        }) as NoUndefinedField<PB.ISmartKnobState>
+                        setSmartKnobState(stateObj)
+                    } else if (message.payload === 'log' && message.log !== null) {
+                        console.log('LOG from smartknob', message.log?.msg)
+                    }
+                })
+                setSmartKnob(smartKnob)
+                const loop = smartKnob.openAndLoop()
+                console.log('FIXME')
+                smartKnob.sendConfig(PB.SmartKnobConfig.create(smartKnobConfig))
+                await loop
+            } else {
+                console.error('Web Serial API is not supported in this browser.')
+                setSmartKnob(null)
+            }
+        } catch (error) {
+            console.error('Error with serial port:', error)
+            setSmartKnob(null)
+        }
+    }
+
+    return (
+        <>
+            <Container component="main" maxWidth="lg">
+                <Paper variant="outlined" sx={{my: {xs: 3, md: 6}, p: {xs: 2, md: 3}}}>
+                    <Typography component="h1" variant="h5">
+                        Basic SmartKnob Web Serial Demo
+                    </Typography>
+                    {smartKnob !== null ? (
+                        <>
+                            <Box
+                                component="form"
+                                sx={{
+                                    '& .MuiTextField-root': {m: 1, width: '25ch'},
+                                }}
+                                noValidate
+                                autoComplete="off"
+                                onSubmit={(event) => {
+                                    event.preventDefault()
+                                    setSmartKnobConfig({
+                                        position: parseInt(pendingSmartKnobConfig.position) || 0,
+                                        subPositionUnit: parseFloat(pendingSmartKnobConfig.subPositionUnit) || 0,
+                                        positionNonce: parseInt(pendingSmartKnobConfig.positionNonce) || 0,
+                                        minPosition: parseInt(pendingSmartKnobConfig.minPosition) || 0,
+                                        maxPosition: parseInt(pendingSmartKnobConfig.maxPosition) || 0,
+                                        positionWidthRadians:
+                                            parseFloat(pendingSmartKnobConfig.positionWidthRadians) || 0,
+                                        detentStrengthUnit: parseFloat(pendingSmartKnobConfig.detentStrengthUnit) || 0,
+                                        endstopStrengthUnit:
+                                            parseFloat(pendingSmartKnobConfig.endstopStrengthUnit) || 0,
+                                        snapPoint: parseFloat(pendingSmartKnobConfig.snapPoint) || 0,
+                                        text: pendingSmartKnobConfig.text,
+                                        detentPositions: [],
+                                        snapPointBias: parseFloat(pendingSmartKnobConfig.snapPointBias) || 0,
+                                        ledHue: 0,
+                                    })
+                                }}
+                            >
+                                <TextField
+                                    label="Position"
+                                    value={pendingSmartKnobConfig.position}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                position: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <TextField
+                                    label="Sub-position unit"
+                                    value={pendingSmartKnobConfig.subPositionUnit}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                subPositionUnit: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <TextField
+                                    label="Position nonce"
+                                    value={pendingSmartKnobConfig.positionNonce}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                positionNonce: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <br />
+                                <TextField
+                                    label="Min position"
+                                    value={pendingSmartKnobConfig.minPosition}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                minPosition: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <TextField
+                                    label="Max position"
+                                    value={pendingSmartKnobConfig.maxPosition}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                maxPosition: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <br />
+                                <TextField
+                                    label="Position width (radians)"
+                                    value={pendingSmartKnobConfig.positionWidthRadians}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                positionWidthRadians: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <br />
+                                <TextField
+                                    label="Detent strength unit"
+                                    value={pendingSmartKnobConfig.detentStrengthUnit}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                detentStrengthUnit: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <TextField
+                                    label="Endstop strength unit"
+                                    value={pendingSmartKnobConfig.endstopStrengthUnit}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                endstopStrengthUnit: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <br />
+                                <TextField
+                                    label="Snap point"
+                                    value={pendingSmartKnobConfig.snapPoint}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                snapPoint: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <TextField
+                                    label="Snap point bias"
+                                    value={pendingSmartKnobConfig.snapPointBias}
+                                    type="number"
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                snapPointBias: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <br />
+                                <TextField
+                                    label="Text"
+                                    value={pendingSmartKnobConfig.text}
+                                    multiline
+                                    onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                                        setPendingSmartKnobConfig((cur) => {
+                                            return {
+                                                ...cur,
+                                                text: event.target.value,
+                                            }
+                                        })
+                                    }}
+                                />
+                                <br />
+                                <Button type="submit" variant="contained">
+                                    Apply
+                                </Button>
+                            </Box>
+                            <pre>{JSON.stringify(smartKnobState, undefined, 2)}</pre>
+                        </>
+                    ) : navigator.serial ? (
+                        <CardActions>
+                            <Button onClick={connectToSerial} variant="contained">
+                                Connect via Web Serial
+                            </Button>
+                        </CardActions>
+                    ) : (
+                        <Typography>
+                            Sorry, Web Serial API isn't available in your browser. Try the latest version of Chrome.
+                        </Typography>
+                    )}
+                </Paper>
+            </Container>
+        </>
+    )
+}

+ 22 - 0
software/js/packages/example-webserial-basic/src/index.tsx

@@ -0,0 +1,22 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import {App} from './App'
+import '@fontsource/roboto/300.css'
+import '@fontsource/roboto/400.css'
+import '@fontsource/roboto/500.css'
+import '@fontsource/roboto/700.css'
+import CssBaseline from '@mui/material/CssBaseline'
+import {createTheme, ThemeProvider} from '@mui/material/styles'
+
+const theme = createTheme()
+
+const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
+
+root.render(
+    <React.StrictMode>
+        <ThemeProvider theme={theme}>
+            <CssBaseline />
+            <App />
+        </ThemeProvider>
+    </React.StrictMode>,
+)

+ 0 - 0
software/js/packages/demo-frontend/src/react-app-env.d.ts → software/js/packages/example-webserial-basic/src/react-app-env.d.ts


+ 0 - 2
software/js/packages/demo-frontend/src/util.ts → software/js/packages/example-webserial-basic/src/util.ts

@@ -18,8 +18,6 @@ export type NoUndefinedField<T> = {
     [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>>
 }
 
-export const INT32_MIN = -2147483648
-
 export function findNClosest(numbers: number[], target: number, n: number): number[] {
     // First, we sort the numbers in ascending order based on their absolute difference
     // from the target number. This means that the numbers that are closest to the target

+ 20 - 0
software/js/packages/example-webserial-basic/tsconfig.json

@@ -0,0 +1,20 @@
+{
+    "compilerOptions": {
+        "target": "es6",
+        "lib": ["dom", "dom.iterable", "esnext"],
+        "allowJs": true,
+        "skipLibCheck": true,
+        "esModuleInterop": true,
+        "allowSyntheticDefaultImports": true,
+        "strict": true,
+        "forceConsistentCasingInFileNames": true,
+        "noFallthroughCasesInSwitch": true,
+        "module": "esnext",
+        "moduleResolution": "node",
+        "resolveJsonModule": true,
+        "isolatedModules": true,
+        "noEmit": true,
+        "jsx": "react-jsx"
+    },
+    "include": ["src"]
+}

+ 0 - 0
software/js/packages/example/.eslintrc → software/js/packages/example-webserial-timeline/.eslintrc


+ 23 - 0
software/js/packages/example-webserial-timeline/.gitignore

@@ -0,0 +1,23 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*

+ 0 - 0
software/js/packages/example/.prettierrc → software/js/packages/example-webserial-timeline/.prettierrc


+ 46 - 0
software/js/packages/example-webserial-timeline/README.md

@@ -0,0 +1,46 @@
+# Getting Started with Create React App
+
+This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
+
+## Available Scripts
+
+In the project directory, you can run:
+
+### `npm start`
+
+Runs the app in the development mode.\
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
+
+The page will reload if you make edits.\
+You will also see any lint errors in the console.
+
+### `npm test`
+
+Launches the test runner in the interactive watch mode.\
+See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
+
+### `npm run build`
+
+Builds the app for production to the `build` folder.\
+It correctly bundles React in production mode and optimizes the build for the best performance.
+
+The build is minified and the filenames include the hashes.\
+Your app is ready to be deployed!
+
+See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
+
+### `npm run eject`
+
+**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
+
+If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
+
+Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
+
+You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
+
+## Learn More
+
+You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
+
+To learn React, check out the [React documentation](https://reactjs.org/).

+ 55 - 0
software/js/packages/example-webserial-timeline/package.json

@@ -0,0 +1,55 @@
+{
+    "name": "example-webserial-timeline",
+    "version": "0.1.0",
+    "private": true,
+    "dependencies": {
+        "@emotion/react": "^11.10.5",
+        "@emotion/styled": "^11.10.5",
+        "@fontsource/roboto": "^4.5.8",
+        "@mui/material": "^5.10.16",
+        "@testing-library/jest-dom": "^5.16.5",
+        "@testing-library/react": "^13.4.0",
+        "@testing-library/user-event": "^13.5.0",
+        "@types/jest": "^27.5.2",
+        "@types/node": "^16.18.4",
+        "@types/react": "^18.0.25",
+        "@types/react-dom": "^18.0.9",
+        "lodash": "^4.17.21",
+        "react": "^18.2.0",
+        "react-dom": "^18.2.0",
+        "react-scripts": "^5.0.1",
+        "smartknobjs-proto": "^1.0.0",
+        "smartknobjs-webserial": "^1.0.0",
+        "typescript": "^4.9.3",
+        "web-vitals": "^2.1.4"
+    },
+    "scripts": {
+        "start": "PORT=3000 react-scripts start",
+        "build": "react-scripts build",
+        "test": "react-scripts test",
+        "eject": "react-scripts eject"
+    },
+    "eslintConfig": {
+        "extends": [
+            "react-app",
+            "react-app/jest"
+        ]
+    },
+    "browserslist": {
+        "production": [
+            ">0.2%",
+            "not dead",
+            "not op_mini all"
+        ],
+        "development": [
+            "last 1 chrome version",
+            "last 1 firefox version",
+            "last 1 safari version"
+        ]
+    },
+    "proxy": "http://localhost:3001",
+    "devDependencies": {
+        "@types/lodash": "^4.14.191",
+        "@types/w3c-web-serial": "^1.0.3"
+    }
+}

BIN
software/js/packages/example-webserial-timeline/public/favicon.ico


+ 15 - 0
software/js/packages/example-webserial-timeline/public/index.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8" />
+        <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+        <meta name="viewport" content="width=device-width, initial-scale=1" />
+        <meta name="theme-color" content="#000000" />
+        <meta name="description" content="Interactive SmartKnob demo using Web Serial" />
+        <title>SmartKnob Demo</title>
+    </head>
+    <body>
+        <noscript>You need to enable JavaScript to run this app.</noscript>
+        <div id="root"></div>
+    </body>
+</html>

+ 3 - 0
software/js/packages/example-webserial-timeline/public/robots.txt

@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:

+ 555 - 0
software/js/packages/example-webserial-timeline/src/App.tsx

@@ -0,0 +1,555 @@
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
+import Typography from '@mui/material/Typography'
+import Container from '@mui/material/Container'
+import ToggleButton from '@mui/material/ToggleButton'
+import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
+import {PB} from 'smartknobjs-proto'
+import {VideoInfo} from './types'
+import {Button, CardActions, Paper} from '@mui/material'
+import {exhaustiveCheck, findNClosest, lerp, NoUndefinedField} from './util'
+import {groupBy, parseInt} from 'lodash'
+import _ from 'lodash'
+import {SmartKnobWebSerial} from 'smartknobjs-webserial'
+
+const MIN_ZOOM = 0.01
+const MAX_ZOOM = 60
+
+const PIXELS_PER_POSITION = 5
+
+enum Mode {
+    Scroll = 'SKDEMO_Scroll',
+    Frames = 'SKDEMO_Frames',
+    Speed = 'SKDEMO_Speed',
+}
+
+type PlaybackState = {
+    speed: number
+    currentFrame: number
+}
+
+type InterfaceState = {
+    zoomTimelinePixelsPerFrame: number
+}
+
+type Config = NoUndefinedField<PB.ISmartKnobConfig> & {
+    zoomTimelinePixelsPerFrame: number
+}
+
+export type AppProps = {
+    info: VideoInfo
+}
+export const App: React.FC<AppProps> = ({info}) => {
+    const [smartKnob, setSmartKnob] = useState<SmartKnobWebSerial | null>(null)
+    const [smartKnobState, setSmartKnobState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
+        PB.SmartKnobState.toObject(PB.SmartKnobState.create({config: PB.SmartKnobConfig.create()}), {
+            defaults: true,
+        }) as NoUndefinedField<PB.ISmartKnobState>,
+    )
+    const [smartKnobConfig, setSmartKnobConfig] = useState<Config>({
+        position: 0,
+        subPositionUnit: 0,
+        positionNonce: Math.floor(Math.random() * 255),
+        minPosition: 0,
+        maxPosition: 0,
+        positionWidthRadians: (15 * Math.PI) / 180,
+        detentStrengthUnit: 0,
+        endstopStrengthUnit: 1,
+        snapPoint: 0.7,
+        text: Mode.Scroll,
+        detentPositions: [],
+        snapPointBias: 0,
+        ledHue: 0,
+
+        zoomTimelinePixelsPerFrame: 0.1,
+    })
+    useEffect(() => {
+        console.log('send config', smartKnobConfig)
+        smartKnob?.sendConfig(PB.SmartKnobConfig.create(smartKnobConfig))
+    }, [
+        smartKnob,
+        smartKnobConfig.position,
+        smartKnobConfig.subPositionUnit,
+        smartKnobConfig.positionNonce,
+        smartKnobConfig.minPosition,
+        smartKnobConfig.maxPosition,
+        smartKnobConfig.positionWidthRadians,
+        smartKnobConfig.detentStrengthUnit,
+        smartKnobConfig.endstopStrengthUnit,
+        smartKnobConfig.snapPoint,
+        smartKnobConfig.text,
+        smartKnobConfig.detentPositions,
+        smartKnobConfig.snapPointBias,
+        smartKnobConfig.ledHue,
+    ])
+    const [playbackState, setPlaybackState] = useState<PlaybackState>({
+        speed: 0,
+        currentFrame: 0,
+    })
+    const [interfaceState, setInterfaceState] = useState<InterfaceState>({
+        zoomTimelinePixelsPerFrame: 0.3,
+    })
+
+    const totalPositions = Math.ceil(
+        (info.totalFrames * smartKnobConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
+    )
+    const detentPositions = useMemo(() => {
+        // Always include the first and last positions at detents
+        const positionsToFrames = groupBy([0, ...info.boundaryFrames, info.totalFrames - 1], (frame) =>
+            Math.round((frame * smartKnobConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION),
+        )
+        console.log(JSON.stringify(positionsToFrames))
+        return positionsToFrames
+    }, [info.boundaryFrames, info.totalFrames, totalPositions, smartKnobConfig.zoomTimelinePixelsPerFrame])
+
+    const scrollPositionWholeMemo = useMemo(() => {
+        const position = (playbackState.currentFrame * smartKnobConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION
+        return Math.round(position)
+    }, [playbackState.currentFrame, smartKnobConfig.zoomTimelinePixelsPerFrame])
+    const [nClosest, setNClosest] = useState<Array<number>>([])
+    useEffect(() => {
+        const calculated = findNClosest(Object.keys(detentPositions).map(parseInt), scrollPositionWholeMemo, 5).sort(
+            (a, b) => a - b,
+        )
+        setNClosest((cur) => {
+            if (_.isEqual(cur, calculated)) {
+                return cur
+            }
+            return calculated
+        })
+    }, [scrollPositionWholeMemo])
+
+    const changeMode = useCallback(
+        (newMode: Mode) => {
+            if (newMode === Mode.Scroll) {
+                setSmartKnobConfig((curConfig) => {
+                    const position =
+                        (playbackState.currentFrame * curConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION
+                    const positionWhole = Math.round(position)
+                    const subPositionUnit = position - positionWhole
+                    return {
+                        position: positionWhole,
+                        subPositionUnit,
+                        positionNonce: (curConfig.positionNonce + 1) % 256,
+                        minPosition: 0,
+                        maxPosition: Math.round(
+                            ((info.totalFrames - 1) * curConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
+                        ),
+                        positionWidthRadians: (8 * Math.PI) / 180,
+                        detentStrengthUnit: 3,
+                        endstopStrengthUnit: 1,
+                        snapPoint: 0.7,
+                        text: Mode.Scroll,
+                        detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), position, 5),
+                        snapPointBias: 0,
+                        ledHue: 0,
+
+                        zoomTimelinePixelsPerFrame: curConfig.zoomTimelinePixelsPerFrame,
+                    }
+                })
+            } else if (newMode === Mode.Frames) {
+                setSmartKnobConfig((curConfig) => {
+                    return {
+                        position: Math.floor(playbackState.currentFrame),
+                        subPositionUnit: 0,
+                        positionNonce: (curConfig.positionNonce + 1) % 256,
+                        minPosition: 0,
+                        maxPosition: info.totalFrames - 1,
+                        positionWidthRadians: (1.8 * Math.PI) / 180,
+                        detentStrengthUnit: 1,
+                        endstopStrengthUnit: 1,
+                        snapPoint: 1.1,
+                        text: Mode.Frames,
+                        detentPositions: [],
+                        snapPointBias: 0,
+                        ledHue: (120 * 255) / 360,
+
+                        zoomTimelinePixelsPerFrame: curConfig.zoomTimelinePixelsPerFrame,
+                    }
+                })
+            } else if (newMode === Mode.Speed) {
+                setSmartKnobConfig((curConfig) => {
+                    return {
+                        position: 0,
+                        subPositionUnit: 0,
+                        positionNonce: (curConfig.positionNonce + 1) % 256,
+                        minPosition: playbackState.currentFrame === 0 ? 0 : -6,
+                        maxPosition: playbackState.currentFrame === info.totalFrames - 1 ? 0 : 6,
+                        positionWidthRadians: (30 * Math.PI) / 180,
+                        detentStrengthUnit: 1,
+                        endstopStrengthUnit: 1,
+                        snapPoint: 0.5,
+                        text: Mode.Speed,
+                        detentPositions: [],
+                        snapPointBias: 0.4,
+                        ledHue: (240 * 255) / 360,
+
+                        zoomTimelinePixelsPerFrame: curConfig.zoomTimelinePixelsPerFrame,
+                    }
+                })
+            } else {
+                exhaustiveCheck(newMode)
+            }
+        },
+        [detentPositions, info.totalFrames, playbackState],
+    )
+    const nextMode = useCallback(() => {
+        const curMode = smartKnobConfig.text as unknown as Mode
+        console.log('nextMode', curMode)
+        if (curMode === Mode.Scroll) {
+            changeMode(Mode.Frames)
+        } else if (curMode === Mode.Frames) {
+            changeMode(Mode.Speed)
+        } else if (curMode === Mode.Speed) {
+            changeMode(Mode.Scroll)
+        } else {
+            exhaustiveCheck(curMode)
+        }
+    }, [smartKnobConfig.text, changeMode])
+
+    // Initialize to Scroll mode
+    useEffect(() => {
+        changeMode(Mode.Scroll)
+    }, [])
+
+    useEffect(() => {
+        if (smartKnobState.config.text === '') {
+            console.debug('No valid state yet')
+            return
+        }
+
+        const currentMode = smartKnobState.config.text as Mode
+        if (currentMode !== smartKnobConfig.text) {
+            console.debug('Mode mismatch, ignoring state', {configMode: smartKnobConfig.text, stateMode: currentMode})
+            return
+        }
+
+        // Update playbackState
+        if (currentMode === Mode.Scroll) {
+            // TODO: round input based on zoom level to avoid noise
+            const rawFrame = Math.trunc(
+                ((smartKnobState.currentPosition + smartKnobState.subPositionUnit) * PIXELS_PER_POSITION) /
+                    smartKnobConfig.zoomTimelinePixelsPerFrame,
+            )
+            const frame =
+                detentPositions[smartKnobState.currentPosition]?.[0] ??
+                Math.min(Math.max(rawFrame, 0), info.totalFrames - 1)
+            setPlaybackState({
+                speed: 0,
+                currentFrame: frame,
+            })
+
+            // Update config with N nearest detents
+            setSmartKnobConfig((curConfig) => {
+                let positionInfo: Partial<Config> = {}
+                if (interfaceState.zoomTimelinePixelsPerFrame !== curConfig.zoomTimelinePixelsPerFrame) {
+                    const position =
+                        (playbackState.currentFrame * interfaceState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION
+                    const positionWhole = Math.round(position)
+                    const subPositionUnit = position - positionWhole
+                    positionInfo = {
+                        position,
+                        subPositionUnit,
+                        positionNonce: (curConfig.positionNonce + 1) % 256,
+                        minPosition: 0,
+                        maxPosition: Math.round(
+                            ((info.totalFrames - 1) * interfaceState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
+                        ),
+                        zoomTimelinePixelsPerFrame: interfaceState.zoomTimelinePixelsPerFrame,
+                    }
+                }
+                return {
+                    ...curConfig,
+                    ...positionInfo,
+                    detentPositions: nClosest,
+                }
+            })
+        } else if (currentMode === Mode.Frames) {
+            setPlaybackState({
+                speed: 0,
+                currentFrame: smartKnobState.currentPosition,
+            })
+            // No config updates needed
+        } else if (currentMode === Mode.Speed) {
+            const normalizedWholeValue = smartKnobState.currentPosition
+            const normalizedFractional =
+                Math.sign(smartKnobState.subPositionUnit) *
+                lerp(smartKnobState.subPositionUnit * Math.sign(smartKnobState.subPositionUnit), 0.1, 0.9, 0, 1)
+            const normalized = normalizedWholeValue + normalizedFractional
+            const speed = Math.sign(normalized) * Math.pow(2, Math.abs(normalized) - 1)
+            const roundedSpeed = Math.trunc(speed * 10) / 10
+            setPlaybackState((cur) => {
+                return {
+                    speed: roundedSpeed,
+                    currentFrame: cur.currentFrame,
+                }
+            })
+
+            // Update config with bounds depending on current frame
+            setSmartKnobConfig((curConfig) => {
+                return {
+                    ...curConfig,
+                    minPosition: playbackState.currentFrame === 0 ? 0 : -6,
+                    maxPosition: playbackState.currentFrame === info.totalFrames - 1 ? 0 : 6,
+                }
+            })
+        } else {
+            exhaustiveCheck(currentMode)
+        }
+    }, [
+        detentPositions,
+        nClosest,
+        info.totalFrames,
+        smartKnobState.config.text,
+        smartKnobState.currentPosition,
+        smartKnobState.subPositionUnit,
+        smartKnobConfig.text,
+        playbackState.currentFrame,
+        playbackState.speed,
+        interfaceState.zoomTimelinePixelsPerFrame,
+    ])
+
+    // Change mode when pressed
+    const receivedPressNonceRef = useRef<boolean>(false)
+    const previousPressNonceRef = useRef<number>(0)
+    useEffect(() => {
+        if (previousPressNonceRef.current !== smartKnobState.pressNonce) {
+            if (!receivedPressNonceRef.current) {
+                // Ignore first nonce change
+                receivedPressNonceRef.current = true
+            } else {
+                nextMode()
+            }
+        }
+        previousPressNonceRef.current = smartKnobState.pressNonce
+    }, [smartKnobState.pressNonce, nextMode])
+
+    const refreshInterval = 20
+    const updateFrame = useCallback(() => {
+        const fps = info.frameRate * playbackState.speed
+        setPlaybackState((cur) => {
+            const newFrame = cur.currentFrame + (fps * refreshInterval) / 1000
+            const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
+            return {
+                speed: cur.speed,
+                currentFrame: clampedNewFrame,
+            }
+        })
+    }, [info.frameRate, playbackState.speed])
+
+    // Store the latest callback in a ref so the long-lived interval closure can invoke the latest version.
+    // See https://overreacted.io/making-setinterval-declarative-with-react-hooks/ for more
+    const savedCallback = useRef<() => void | null>()
+    useEffect(() => {
+        savedCallback.current = updateFrame
+    }, [updateFrame])
+
+    const isPlaying = useMemo(() => {
+        return playbackState.speed !== 0
+    }, [playbackState.speed])
+
+    useEffect(() => {
+        if (smartKnobState.config.text === Mode.Speed && isPlaying) {
+            const timer = setInterval(() => {
+                if (savedCallback.current) {
+                    savedCallback.current()
+                }
+            }, refreshInterval)
+            return () => clearInterval(timer)
+        }
+    }, [smartKnobState.config.text, isPlaying])
+
+    const connectToSerial = async () => {
+        try {
+            if (navigator.serial) {
+                previousPressNonceRef.current = 0
+                receivedPressNonceRef.current = false
+
+                const serialPort = await navigator.serial.requestPort({
+                    filters: SmartKnobWebSerial.USB_DEVICE_FILTERS,
+                })
+                serialPort.addEventListener('disconnect', () => {
+                    setSmartKnob(null)
+                })
+                const smartKnob = new SmartKnobWebSerial(serialPort, (message) => {
+                    if (message.payload === 'smartknobState' && message.smartknobState !== null) {
+                        const state = PB.SmartKnobState.create(message.smartknobState)
+                        const stateObj = PB.SmartKnobState.toObject(state, {
+                            defaults: true,
+                        }) as NoUndefinedField<PB.ISmartKnobState>
+                        setSmartKnobState(stateObj)
+                    } else if (message.payload === 'log' && message.log !== null) {
+                        console.log('LOG from smartknob', message.log?.msg)
+                    }
+                })
+                setSmartKnob(smartKnob)
+                await smartKnob.openAndLoop()
+            } else {
+                console.error('Web Serial API is not supported in this browser.')
+                setSmartKnob(null)
+            }
+        } catch (error) {
+            console.error('Error with serial port:', error)
+            setSmartKnob(null)
+        }
+    }
+
+    return (
+        <>
+            <Container component="main" maxWidth="lg">
+                <Paper variant="outlined" sx={{my: {xs: 3, md: 6}, p: {xs: 2, md: 3}}}>
+                    <Typography component="h1" variant="h5">
+                        Video Playback Control Demo
+                    </Typography>
+                    {smartKnob !== null ? (
+                        <>
+                            <ToggleButtonGroup
+                                color="primary"
+                                value={smartKnobConfig.text}
+                                exclusive
+                                onChange={(e, value: Mode | null) => {
+                                    if (value === null) {
+                                        return
+                                    }
+                                    changeMode(value)
+                                }}
+                                aria-label="Mode"
+                            >
+                                {Object.entries(Mode).map((mode_entry) => (
+                                    <ToggleButton value={mode_entry[1]} key={mode_entry[1]}>
+                                        {mode_entry[0]}
+                                    </ToggleButton>
+                                ))}
+                            </ToggleButtonGroup>
+                            <Typography>
+                                Frame {Math.trunc(playbackState.currentFrame)} / {info.totalFrames - 1}
+                                <br />
+                                Speed {playbackState.speed}
+                            </Typography>
+                            <Timeline
+                                info={info}
+                                currentFrame={playbackState.currentFrame}
+                                zoomTimelinePixelsPerFrame={interfaceState.zoomTimelinePixelsPerFrame}
+                                adjustZoom={(factor) => {
+                                    setInterfaceState((cur) => {
+                                        const newZoom = Math.min(
+                                            Math.max(cur.zoomTimelinePixelsPerFrame * factor, MIN_ZOOM),
+                                            MAX_ZOOM,
+                                        )
+                                        console.log(factor, newZoom)
+                                        return {
+                                            ...cur,
+                                            zoomTimelinePixelsPerFrame: newZoom,
+                                        }
+                                    })
+                                }}
+                            />
+                        </>
+                    ) : navigator.serial ? (
+                        <CardActions>
+                            <Button onClick={connectToSerial} variant="contained">
+                                Connect via Web Serial
+                            </Button>
+                        </CardActions>
+                    ) : (
+                        <Typography>
+                            Sorry, Web Serial API isn't available in your browser. Try the latest version of Chrome.
+                        </Typography>
+                    )}
+                    <pre>{JSON.stringify(smartKnobConfig, undefined, 2)}</pre>
+                </Paper>
+            </Container>
+        </>
+    )
+}
+
+export type TimelineProps = {
+    info: VideoInfo
+    currentFrame: number
+    zoomTimelinePixelsPerFrame: number
+    adjustZoom: (factor: number) => void
+}
+export const Timeline: React.FC<TimelineProps> = ({info, currentFrame, zoomTimelinePixelsPerFrame, adjustZoom}) => {
+    const gradients = [
+        'linear-gradient( 135deg, #FDEB71 10%, #F8D800 100%)',
+        'linear-gradient( 135deg, #ABDCFF 10%, #0396FF 100%)',
+        'linear-gradient( 135deg, #FEB692 10%, #EA5455 100%)',
+        'linear-gradient( 135deg, #CE9FFC 10%, #7367F0 100%)',
+        'linear-gradient( 135deg, #90F7EC 10%, #32CCBC 100%)',
+    ]
+
+    const timelineRef = useRef<HTMLDivElement>(null)
+    const cursorRef = useRef<HTMLDivElement>(null)
+
+    useEffect(() => {
+        const handleWheel = (event: HTMLElementEventMap['wheel']) => {
+            const delta = event.deltaY
+            if (delta) {
+                event.preventDefault()
+                adjustZoom(1 - delta / 500)
+            }
+        }
+
+        timelineRef.current?.addEventListener('wheel', handleWheel)
+        return () => {
+            timelineRef.current?.removeEventListener('wheel', handleWheel)
+        }
+    }, [])
+
+    useEffect(() => {
+        cursorRef.current?.scrollIntoView()
+    }, [currentFrame, zoomTimelinePixelsPerFrame])
+    return (
+        <div
+            className="timeline-container"
+            ref={timelineRef}
+            style={{
+                width: '100%',
+                margin: '10px auto',
+                overflowX: 'scroll',
+            }}
+        >
+            <div
+                className="timeline"
+                style={{
+                    position: 'relative',
+                    display: 'inline-block',
+                    height: '80px',
+                    width: `${zoomTimelinePixelsPerFrame * info.totalFrames}px`,
+                    backgroundColor: '#dde',
+                }}
+            >
+                {[...info.boundaryFrames, info.totalFrames].map((f, i, a) => {
+                    const lengthFrames = f - (a[i - 1] ?? 0)
+                    const widthPixels = zoomTimelinePixelsPerFrame * lengthFrames
+                    return (
+                        <div
+                            key={`clip-${f}`}
+                            className="video-clip"
+                            style={{
+                                position: 'relative',
+                                display: 'inline-block',
+                                top: '10px',
+                                height: '60px',
+                                width: `${widthPixels}px`,
+                                backgroundImage: gradients[i % gradients.length],
+                            }}
+                        ></div>
+                    )
+                })}
+                <div
+                    className="playback-cursor"
+                    ref={cursorRef}
+                    style={{
+                        position: 'absolute',
+                        display: 'inline-block',
+                        left: `${zoomTimelinePixelsPerFrame * Math.trunc(currentFrame)}px`,
+                        width: `${Math.max(zoomTimelinePixelsPerFrame, 1)}px`,
+                        height: '100%',
+                        backgroundColor: 'rgba(255, 0, 0, 0.4)',
+                        borderLeft: '1px solid red',
+                    }}
+                ></div>
+            </div>
+        </div>
+    )
+}

+ 1 - 7
software/js/packages/demo-frontend/src/index.tsx → software/js/packages/example-webserial-timeline/src/index.tsx

@@ -1,7 +1,6 @@
 import React from 'react'
 import ReactDOM from 'react-dom/client'
 import {App} from './App'
-import reportWebVitals from './reportWebVitals'
 import '@fontsource/roboto/300.css'
 import '@fontsource/roboto/400.css'
 import '@fontsource/roboto/500.css'
@@ -17,7 +16,7 @@ const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
 const info: VideoInfo = {
     totalFrames: 30 * 60 * 5,
     frameRate: 30,
-    boundaryFrames: [312, 400, 1234, 1290, 3000, 4000],
+    boundaryFrames: [312, 400, 1234, 1290, 3000, 3300, 4000, 8000, 8100],
 }
 root.render(
     <React.StrictMode>
@@ -27,8 +26,3 @@ root.render(
         </ThemeProvider>
     </React.StrictMode>,
 )
-
-// If you want to start measuring performance in your app, pass a function
-// to log results (for example: reportWebVitals(console.log))
-// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
-reportWebVitals()

+ 1 - 0
software/js/packages/example-webserial-timeline/src/react-app-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="react-scripts" />

+ 0 - 0
software/js/packages/demo-frontend/src/types.tsx → software/js/packages/example-webserial-timeline/src/types.tsx


+ 29 - 0
software/js/packages/example-webserial-timeline/src/util.ts

@@ -0,0 +1,29 @@
+export const exhaustiveCheck = (x: never): never => {
+    throw new Error(`Unexpected value: ${x}`, x)
+}
+
+export const isSome = <T>(v: T | null | undefined): v is T => {
+    return v !== null && v !== undefined
+}
+
+export const lerp = (value: number, inMin: number, inMax: number, min: number, max: number): number => {
+    // Map the input value from the input range to the output range
+    value = ((value - inMin) / (inMax - inMin)) * (max - min) + min
+
+    // Clamp the mapped value between the minimum and maximum range
+    return Math.min(Math.max(value, min), max)
+}
+
+export type NoUndefinedField<T> = {
+    [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>>
+}
+
+export function findNClosest(numbers: number[], target: number, n: number): number[] {
+    // First, we sort the numbers in ascending order based on their absolute difference
+    // from the target number. This means that the numbers that are closest to the target
+    // will come first in the sorted array.
+    const sortedNumbers = numbers.sort((a, b) => Math.abs(a - target) - Math.abs(b - target))
+
+    // Next, we return the first N numbers from the sorted array as the N closest numbers.
+    return sortedNumbers.slice(0, n)
+}

+ 20 - 0
software/js/packages/example-webserial-timeline/tsconfig.json

@@ -0,0 +1,20 @@
+{
+    "compilerOptions": {
+        "target": "es6",
+        "lib": ["dom", "dom.iterable", "esnext"],
+        "allowJs": true,
+        "skipLibCheck": true,
+        "esModuleInterop": true,
+        "allowSyntheticDefaultImports": true,
+        "strict": true,
+        "forceConsistentCasingInFileNames": true,
+        "noFallthroughCasesInSwitch": true,
+        "module": "esnext",
+        "moduleResolution": "node",
+        "resolveJsonModule": true,
+        "isolatedModules": true,
+        "noEmit": true,
+        "jsx": "react-jsx"
+    },
+    "include": ["src"]
+}

+ 0 - 105
software/js/packages/example/tsconfig.json

@@ -1,105 +0,0 @@
-{
-  "compilerOptions": {
-    /* Visit https://aka.ms/tsconfig.json to read more about this file */
-
-    /* Projects */
-    // "incremental": true,                              /* Enable incremental compilation */
-    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
-    // "tsBuildInfoFile": "./",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */
-    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */
-    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
-    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
-
-    /* Language and Environment */
-    "target": "es5",                                     /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
-    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
-    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
-    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
-    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
-    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
-    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
-    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
-    // "reactNamespace": "",                             /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
-    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
-    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
-
-    /* Modules */
-    "module": "commonjs",                                /* Specify what module code is generated. */
-    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
-    // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
-    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
-    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
-    // "paths" :{
-    //   "*": ["./src/typings/*", "./*"]
-    // },
-    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
-    // "typeRoots": ["src/typings"],                        /* Specify multiple folders that act like `./node_modules/@types`. */
-    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
-    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
-    // "resolveJsonModule": true,                        /* Enable importing .json files */
-    // "noResolve": true,                                /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
-
-    /* JavaScript Support */
-    "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
-    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
-    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
-
-    /* Emit */
-    "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
-    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
-    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
-    // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
-    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
-    "outDir": "./dist",                                   /* Specify an output folder for all emitted files. */
-    // "removeComments": true,                           /* Disable emitting comments. */
-    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
-    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
-    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types */
-    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
-    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
-    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
-    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
-    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
-    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
-    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
-    // "stripInternal": true,                            /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
-    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like `__extends` in compiled output. */
-    "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
-    // "preserveConstEnums": true,                       /* Disable erasing `const enum` declarations in generated code. */
-    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
-
-    /* Interop Constraints */
-    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
-    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
-    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
-    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
-    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
-
-    /* Type Checking */
-    "strict": true,                                      /* Enable all strict type-checking options. */
-    "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied `any` type.. */
-    "strictNullChecks": true,                         /* When type checking, take into account `null` and `undefined`. */
-    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
-    // "strictBindCallApply": true,                      /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
-    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
-    // "noImplicitThis": true,                           /* Enable error reporting when `this` is given the type `any`. */
-    // "useUnknownInCatchVariables": true,               /* Type catch clause variables as 'unknown' instead of 'any'. */
-    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
-    // "noUnusedLocals": true,                           /* Enable error reporting when a local variables aren't read. */
-    // "noUnussedParameters": true,                       /* Raise an error when a function parameter isn't read */
-    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
-    "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
-    "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
-    // "noUncheckedIndexedAccess": true,                 /* Include 'undefined' in index signature results */
-    "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
-    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type */
-    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
-    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
-
-    /* Completeness */
-    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
-    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
-  },
-  "include": ["src"],
-  "exclude": ["node_modules"]
-}

+ 0 - 0
software/js/packages/smartknobjs/.eslintrc → software/js/packages/smartknobjs-core/.eslintrc


+ 0 - 0
software/js/packages/smartknobjs/.prettierrc → software/js/packages/smartknobjs-core/.prettierrc


+ 26 - 0
software/js/packages/smartknobjs-core/package.json

@@ -0,0 +1,26 @@
+{
+    "name": "smartknobjs-core",
+    "version": "1.0.0",
+    "description": "SmartKnob Interface Core",
+    "main": "dist/index.js",
+    "types": "dist/index.d.ts",
+    "scripts": {
+        "build": "tsc",
+        "format": "prettier --write \"**/*.+(js|ts|json)\"",
+        "lint": "eslint --ext .js,.ts ."
+    },
+    "author": "",
+    "license": "Apache-2.0",
+    "dependencies": {
+        "crc-32": "^1.2.0",
+        "smartknobjs-proto": "^1.0.0"
+    },
+    "devDependencies": {
+        "@typescript-eslint/eslint-plugin": "^5.40.1",
+        "@typescript-eslint/parser": "^5.40.1",
+        "eslint": "^8.25.0",
+        "prettier": "^2.4.1",
+        "ts-node": "^10.2.1",
+        "typescript": "^4.8.4"
+    }
+}

+ 43 - 0
software/js/packages/smartknobjs-core/src/cobs.ts

@@ -0,0 +1,43 @@
+// Based on https://github.com/tcr/node-cobs/blob/master/index.js
+
+export function encode(buf: Uint8Array) {
+    const dest = [0]
+    let code_ptr = 0
+    let code = 0x01
+
+    const finish = (incllast?: boolean) => {
+        dest[code_ptr] = code
+        code_ptr = dest.length
+        incllast !== false && dest.push(0x00)
+        code = 0x01
+    }
+
+    for (let i = 0; i < buf.length; i++) {
+        if (buf[i] == 0) {
+            finish()
+        } else {
+            dest.push(buf[i])
+            code += 1
+            if (code == 0xff) {
+                finish()
+            }
+        }
+    }
+    finish(false)
+
+    return Uint8Array.from(dest)
+}
+
+export function decode(buf: Uint8Array) {
+    const dest: number[] = []
+    for (let i = 0; i < buf.length; ) {
+        const code = buf[i++]
+        for (let j = 1; j < code; j++) {
+            dest.push(buf[i++])
+        }
+        if (code < 0xff && i < buf.length) {
+            dest.push(0)
+        }
+    }
+    return Uint8Array.from(dest)
+}

+ 62 - 66
software/js/packages/smartknobjs/src/index.ts → software/js/packages/smartknobjs-core/src/index.ts

@@ -1,89 +1,69 @@
-import SerialPort = require('serialport')
-import {decode, encode} from 'cobs'
+import {encode as cobsEncode, decode as cobsDecode} from './cobs'
 import * as CRC32 from 'crc-32'
 
 import {PB} from 'smartknobjs-proto'
 
+const PROTOBUF_PROTOCOL_VERSION = 1
+
 export type MessageCallback = (message: PB.FromSmartKnob) => void
+export type SendBytes = (packet: Uint8Array) => void
 
 type QueueEntry = {
     nonce: number
     encodedToSmartknobPayload: Uint8Array
 }
 
-const sleep = (millis: number) => {
-    return new Promise((resolve) => {
-        setTimeout(resolve, millis)
-    })
-}
+export {cobsEncode, cobsDecode}
 
-export class SmartKnob {
+export class SmartKnobCore {
     private static readonly RETRY_MILLIS = 250
-    private static readonly BAUD = 921600
+    public static readonly BAUD = 921600
+    public static readonly USB_DEVICE_FILTERS = [
+        // CH340
+        {
+            usbVendorId: 0x1a86,
+            usbProductId: 0x7523,
+        },
+        // ESP32-S3
+        {
+            usbVendorId: 0x303a,
+            usbProductId: 0x1001,
+        },
+    ]
 
-    private port: SerialPort | null
     private onMessage: MessageCallback
-    private buffer: Buffer
+    private sendBytes: SendBytes
 
-    private outgoingQueue: QueueEntry[] = []
+    private readonly outgoingQueue: QueueEntry[] = []
 
     private lastNonce = 1
-    private retryTimeout: NodeJS.Timeout | null = null
-
-    private currentConfig: PB.SmartKnobConfig
-
-    constructor(serialPath: string | null, onMessage: MessageCallback) {
-        this.onMessage = onMessage
-
-        this.buffer = Buffer.alloc(0)
+    private retryTimeout: ReturnType<typeof setTimeout> | null = null
+    protected portAvailable = false
 
-        if (serialPath !== null) {
-            this.port = new SerialPort(serialPath, {
-                baudRate: SmartKnob.BAUD,
-            })
-            this.port.on('data', (data: Buffer) => {
-                this.buffer = Buffer.concat([this.buffer, data])
-                this.processBuffer()
-            })
-        } else {
-            this.port = null
-        }
-
-        this.currentConfig = PB.SmartKnobConfig.create({})
+    private buffer = new Uint8Array()
 
+    constructor(onMessage: MessageCallback, sendBytes: SendBytes) {
         this.lastNonce = Math.floor(Math.random() * (2 ^ (32 - 1)))
-    }
-
-    /**
-     * Perform a hard reset of the MCU. Takes a few seconds.
-     */
-    public async hardReset(): Promise<void> {
-        if (this.port === null) {
-            console.warn("Not connected to SmartKnob, so hard reset isn't possible")
-            return
-        }
-        this.outgoingQueue = []
-        this.port.set({rts: true, dtr: false})
-        await sleep(200)
-        this.port.set({rts: true, dtr: true})
-        await sleep(200)
-        return
+        this.onMessage = onMessage
+        this.sendBytes = sendBytes
     }
 
     public sendConfig(config: PB.SmartKnobConfig): void {
-        this.sendMessage(
+        this.enqueueMessage(
             PB.ToSmartknob.create({
                 smartknobConfig: config,
             }),
         )
     }
 
-    private processBuffer(): void {
+    protected onReceivedData(data: Uint8Array) {
+        this.buffer = Uint8Array.from([...this.buffer, ...data])
+
         let i: number
         // Iterate 0-delimited packets
         while ((i = this.buffer.indexOf(0)) != -1) {
-            const raw_buffer = this.buffer.slice(0, i)
-            const packet = decode(raw_buffer) as Buffer
+            const raw_buffer = this.buffer.subarray(0, i)
+            const packet = cobsDecode(raw_buffer)
             this.buffer = this.buffer.slice(i + 1)
             if (packet.length <= 4) {
                 console.debug(`Received short packet ${this.buffer.slice(0, i)}`)
@@ -108,7 +88,12 @@ export class SmartKnob {
                 console.warn(`Invalid protobuf message ${payload}`)
                 return
             }
-
+            if (message.protocolVersion !== PROTOBUF_PROTOCOL_VERSION) {
+                console.warn(
+                    `Invalid protocol version. Expected ${PROTOBUF_PROTOCOL_VERSION}, received ${message.protocolVersion}`,
+                )
+                return
+            }
             if (message.payload === 'ack') {
                 const nonce = message.ack?.nonce ?? undefined
                 if (nonce === undefined) {
@@ -117,15 +102,15 @@ export class SmartKnob {
                     this.handleAck(nonce)
                 }
             }
-
             this.onMessage(message)
         }
     }
 
-    private sendMessage(message: PB.ToSmartknob) {
-        if (this.port === null) {
+    private enqueueMessage(message: PB.ToSmartknob) {
+        if (!this.portAvailable) {
             return
         }
+        message.protocolVersion = PROTOBUF_PROTOCOL_VERSION
         message.nonce = this.lastNonce++
 
         // Encode before enqueueing to ensure messages don't change once they're queued
@@ -151,12 +136,12 @@ export class SmartKnob {
             this.outgoingQueue.shift()
             this.serviceQueue()
         } else {
-            console.debug(`Ignoring unexpected ack for nonce ${nonce}`)
+            console.log(`Ignoring unexpected ack for nonce ${nonce}`)
         }
     }
 
     private serviceQueue(): void {
-        if (this.port === null) {
+        if (!this.portAvailable) {
             return
         }
         if (this.retryTimeout !== null) {
@@ -166,22 +151,33 @@ export class SmartKnob {
         if (this.outgoingQueue.length === 0) {
             return
         }
-
         const {encodedToSmartknobPayload: payload} = this.outgoingQueue[0]
 
         const crc = CRC32.buf(payload)
-        const crcBuffer = Buffer.from([crc & 0xff, (crc >>> 8) & 0xff, (crc >>> 16) & 0xff, (crc >>> 24) & 0xff])
-        const packet = Buffer.concat([payload, crcBuffer])
+        const crcArray = [crc & 0xff, (crc >>> 8) & 0xff, (crc >>> 16) & 0xff, (crc >>> 24) & 0xff]
+
+        const packet = new Uint8Array(payload.length + 4)
+        packet.set(payload, 0)
+        packet.set(crcArray, payload.length)
 
-        const encodedDelimitedPacket = Buffer.concat([encode(packet), Buffer.from([0])])
+        const cobsEncodedPacket = cobsEncode(packet)
+
+        const encodedDelimitedPacket = new Uint8Array(cobsEncodedPacket.length + 1)
+        encodedDelimitedPacket.set(cobsEncodedPacket, 0)
+        encodedDelimitedPacket.set([0], cobsEncodedPacket.length)
 
         this.retryTimeout = setTimeout(() => {
             this.retryTimeout = null
             console.log(`Retrying ToSmartknob...`)
             this.serviceQueue()
-        }, SmartKnob.RETRY_MILLIS)
+        }, SmartKnobCore.RETRY_MILLIS)
 
-        console.debug(`Sent ${payload.length} byte packet with CRC ${(crc >>> 0).toString(16)}`)
-        this.port.write(encodedDelimitedPacket)
+        console.debug(
+            `Sent ${payload.length} byte payload with CRC ${(crc >>> 0).toString(16)} (${
+                cobsEncodedPacket.length
+            } bytes encoded)`,
+            encodedDelimitedPacket,
+        )
+        this.sendBytes(encodedDelimitedPacket)
     }
 }

+ 105 - 0
software/js/packages/smartknobjs-core/tsconfig.json

@@ -0,0 +1,105 @@
+{
+    "compilerOptions": {
+        /* Visit https://aka.ms/tsconfig.json to read more about this file */
+
+        /* Projects */
+        // "incremental": true,                              /* Enable incremental compilation */
+        // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
+        // "tsBuildInfoFile": "./",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */
+        // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */
+        // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
+        // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
+
+        /* Language and Environment */
+        "target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
+        // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+        // "jsx": "preserve",                                /* Specify what JSX code is generated. */
+        // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
+        // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
+        // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
+        // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+        // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
+        // "reactNamespace": "",                             /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
+        // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
+        // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
+
+        /* Modules */
+        "module": "commonjs" /* Specify what module code is generated. */,
+        // "rootDir": "./",                                  /* Specify the root folder within your source files. */
+        // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
+        // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
+        // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
+        // "paths" :{
+        //   "*": ["./src/typings/*", "./*"]
+        // },
+        // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
+        // "typeRoots": ["src/typings"],                        /* Specify multiple folders that act like `./node_modules/@types`. */
+        // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
+        // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
+        // "resolveJsonModule": true,                        /* Enable importing .json files */
+        // "noResolve": true,                                /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
+
+        /* JavaScript Support */
+        "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
+        // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
+        // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
+
+        /* Emit */
+        "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
+        // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
+        // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
+        // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
+        // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
+        "outDir": "./dist" /* Specify an output folder for all emitted files. */,
+        // "removeComments": true,                           /* Disable emitting comments. */
+        // "noEmit": true,                                   /* Disable emitting files from a compilation. */
+        // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+        // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types */
+        // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+        // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
+        // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
+        // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
+        // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
+        // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+        // "newLine": "crlf",                                /* Set the newline character for emitting files. */
+        // "stripInternal": true,                            /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
+        // "noEmitHelpers": true,                            /* Disable generating custom helper functions like `__extends` in compiled output. */
+        "noEmitOnError": true /* Disable emitting files if any type checking errors are reported. */,
+        // "preserveConstEnums": true,                       /* Disable erasing `const enum` declarations in generated code. */
+        // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
+
+        /* Interop Constraints */
+        // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
+        // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
+        "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
+        // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+        "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
+
+        /* Type Checking */
+        "strict": true /* Enable all strict type-checking options. */,
+        "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied `any` type.. */,
+        "strictNullChecks": true /* When type checking, take into account `null` and `undefined`. */,
+        // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+        // "strictBindCallApply": true,                      /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
+        // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
+        // "noImplicitThis": true,                           /* Enable error reporting when `this` is given the type `any`. */
+        // "useUnknownInCatchVariables": true,               /* Type catch clause variables as 'unknown' instead of 'any'. */
+        // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
+        // "noUnusedLocals": true,                           /* Enable error reporting when a local variables aren't read. */
+        // "noUnussedParameters": true,                       /* Raise an error when a function parameter isn't read */
+        // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
+        "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */,
+        "noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */,
+        // "noUncheckedIndexedAccess": true,                 /* Include 'undefined' in index signature results */
+        "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */,
+        // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type */
+        // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
+        // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
+
+        /* Completeness */
+        // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
+        "skipLibCheck": true /* Skip type checking all .d.ts files. */
+    },
+    "include": ["src"],
+    "exclude": ["node_modules"]
+}

+ 19 - 0
software/js/packages/smartknobjs-node/.eslintrc

@@ -0,0 +1,19 @@
+// .eslintrc
+{
+    "parser": "@typescript-eslint/parser",
+    "parserOptions": {
+        "ecmaVersion": 12,
+        "sourceType": "module"
+    },
+    "plugins": ["@typescript-eslint"],
+    "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
+
+    "rules": {
+        "@typescript-eslint/no-unused-vars": "error",
+        "@typescript-eslint/consistent-type-definitions": ["error", "type"]
+    },
+
+    "env": {
+        "node": true
+    }
+}

+ 10 - 0
software/js/packages/smartknobjs-node/.prettierrc

@@ -0,0 +1,10 @@
+{
+    "printWidth": 120,
+    "tabWidth": 4,
+    "useTabs": false,
+    "semi": false,
+    "singleQuote": true,
+    "trailingComma": "all",
+    "bracketSpacing": false,
+    "arrowParens": "always"
+}

+ 5 - 7
software/js/packages/smartknobjs/package.json → software/js/packages/smartknobjs-node/package.json

@@ -1,7 +1,7 @@
 {
-    "name": "smartknobjs",
-    "version": "0.1.0",
-    "description": "SmartKnob Interface Library",
+    "name": "smartknobjs-node",
+    "version": "1.0.0",
+    "description": "SmartKnob Interface Library for Node.js",
     "main": "dist/index.js",
     "types": "dist/index.d.ts",
     "scripts": {
@@ -12,13 +12,11 @@
     "author": "",
     "license": "Apache-2.0",
     "dependencies": {
-        "cobs": "^0.2.1",
-        "crc-32": "^1.2.0",
         "serialport": "^9.2.4",
-        "smartknobjs-proto": "^0.1.0"
+        "smartknobjs-core": "^1.0.0",
+        "smartknobjs-proto": "^1.0.0"
     },
     "devDependencies": {
-        "@types/serialport": "^8.0.2",
         "@typescript-eslint/eslint-plugin": "^5.40.1",
         "@typescript-eslint/parser": "^5.40.1",
         "eslint": "^8.25.0",

+ 19 - 0
software/js/packages/smartknobjs-node/src/index.ts

@@ -0,0 +1,19 @@
+import SerialPort = require('serialport')
+import {MessageCallback, SmartKnobCore} from 'smartknobjs-core'
+
+export class SmartKnobNode extends SmartKnobCore {
+    private port: SerialPort | null
+
+    constructor(serialPath: string, onMessage: MessageCallback) {
+        super(onMessage, (packet: Uint8Array) => {
+            this.port?.write(Buffer.from(packet))
+        })
+        this.port = new SerialPort(serialPath, {
+            baudRate: SmartKnobCore.BAUD,
+        })
+        this.port.on('data', (data) => {
+            this.onReceivedData(data)
+        })
+        this.portAvailable = true
+    }
+}

+ 105 - 0
software/js/packages/smartknobjs-node/tsconfig.json

@@ -0,0 +1,105 @@
+{
+    "compilerOptions": {
+        /* Visit https://aka.ms/tsconfig.json to read more about this file */
+
+        /* Projects */
+        // "incremental": true,                              /* Enable incremental compilation */
+        // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
+        // "tsBuildInfoFile": "./",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */
+        // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */
+        // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
+        // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
+
+        /* Language and Environment */
+        "target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
+        // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+        // "jsx": "preserve",                                /* Specify what JSX code is generated. */
+        // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
+        // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
+        // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
+        // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+        // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
+        // "reactNamespace": "",                             /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
+        // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
+        // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
+
+        /* Modules */
+        "module": "commonjs" /* Specify what module code is generated. */,
+        // "rootDir": "./",                                  /* Specify the root folder within your source files. */
+        // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
+        // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
+        // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
+        // "paths" :{
+        //   "*": ["./src/typings/*", "./*"]
+        // },
+        // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
+        // "typeRoots": ["src/typings"],                        /* Specify multiple folders that act like `./node_modules/@types`. */
+        // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
+        // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
+        // "resolveJsonModule": true,                        /* Enable importing .json files */
+        // "noResolve": true,                                /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
+
+        /* JavaScript Support */
+        "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
+        // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
+        // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
+
+        /* Emit */
+        "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
+        // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
+        // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
+        // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
+        // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
+        "outDir": "./dist" /* Specify an output folder for all emitted files. */,
+        // "removeComments": true,                           /* Disable emitting comments. */
+        // "noEmit": true,                                   /* Disable emitting files from a compilation. */
+        // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+        // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types */
+        // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+        // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
+        // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
+        // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
+        // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
+        // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+        // "newLine": "crlf",                                /* Set the newline character for emitting files. */
+        // "stripInternal": true,                            /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
+        // "noEmitHelpers": true,                            /* Disable generating custom helper functions like `__extends` in compiled output. */
+        "noEmitOnError": true /* Disable emitting files if any type checking errors are reported. */,
+        // "preserveConstEnums": true,                       /* Disable erasing `const enum` declarations in generated code. */
+        // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
+
+        /* Interop Constraints */
+        // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
+        // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
+        "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
+        // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+        "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
+
+        /* Type Checking */
+        "strict": true /* Enable all strict type-checking options. */,
+        "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied `any` type.. */,
+        "strictNullChecks": true /* When type checking, take into account `null` and `undefined`. */,
+        // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+        // "strictBindCallApply": true,                      /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
+        // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
+        // "noImplicitThis": true,                           /* Enable error reporting when `this` is given the type `any`. */
+        // "useUnknownInCatchVariables": true,               /* Type catch clause variables as 'unknown' instead of 'any'. */
+        // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
+        // "noUnusedLocals": true,                           /* Enable error reporting when a local variables aren't read. */
+        // "noUnussedParameters": true,                       /* Raise an error when a function parameter isn't read */
+        // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
+        "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */,
+        "noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */,
+        // "noUncheckedIndexedAccess": true,                 /* Include 'undefined' in index signature results */
+        "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */,
+        // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type */
+        // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
+        // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
+
+        /* Completeness */
+        // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
+        "skipLibCheck": true /* Skip type checking all .d.ts files. */
+    },
+    "include": ["src"],
+    "exclude": ["node_modules"]
+}

+ 1 - 1
software/js/packages/smartknobjs-proto/package.json

@@ -1,6 +1,6 @@
 {
   "name": "smartknobjs-proto",
-  "version": "0.1.1",
+  "version": "1.0.0",
   "description": "SmartKnob Protobuf Generated Code",
   "main": "dist/smartknob_proto.js",
   "types": "dist/smartknob_proto.d.ts",

+ 19 - 0
software/js/packages/smartknobjs-webserial/.eslintrc

@@ -0,0 +1,19 @@
+// .eslintrc
+{
+    "parser": "@typescript-eslint/parser",
+    "parserOptions": {
+        "ecmaVersion": 12,
+        "sourceType": "module"
+    },
+    "plugins": ["@typescript-eslint"],
+    "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
+
+    "rules": {
+        "@typescript-eslint/no-unused-vars": "error",
+        "@typescript-eslint/consistent-type-definitions": ["error", "type"]
+    },
+
+    "env": {
+        "node": true
+    }
+}

+ 10 - 0
software/js/packages/smartknobjs-webserial/.prettierrc

@@ -0,0 +1,10 @@
+{
+    "printWidth": 120,
+    "tabWidth": 4,
+    "useTabs": false,
+    "semi": false,
+    "singleQuote": true,
+    "trailingComma": "all",
+    "bracketSpacing": false,
+    "arrowParens": "always"
+}

+ 27 - 0
software/js/packages/smartknobjs-webserial/package.json

@@ -0,0 +1,27 @@
+{
+    "name": "smartknobjs-webserial",
+    "version": "1.0.0",
+    "description": "SmartKnob Interface Library for Web Serial",
+    "main": "dist/index.js",
+    "types": "dist/index.d.ts",
+    "scripts": {
+        "build": "tsc",
+        "format": "prettier --write \"**/*.+(js|ts|json)\"",
+        "lint": "eslint --ext .js,.ts ."
+    },
+    "author": "",
+    "license": "Apache-2.0",
+    "dependencies": {
+        "smartknobjs-core": "^1.0.0",
+        "smartknobjs-proto": "^1.0.0"
+    },
+    "devDependencies": {
+        "@typescript-eslint/eslint-plugin": "^5.40.1",
+        "@typescript-eslint/parser": "^5.40.1",
+        "@types/w3c-web-serial": "^1.0.3",
+        "eslint": "^8.25.0",
+        "prettier": "^2.4.1",
+        "ts-node": "^10.2.1",
+        "typescript": "^4.8.4"
+    }
+}

+ 62 - 0
software/js/packages/smartknobjs-webserial/src/index.ts

@@ -0,0 +1,62 @@
+import {MessageCallback, SmartKnobCore} from 'smartknobjs-core'
+
+export class SmartKnobWebSerial extends SmartKnobCore {
+    private port: SerialPort | null
+    private writer: WritableStreamDefaultWriter<Uint8Array> | undefined = undefined
+
+    constructor(port: SerialPort, onMessage: MessageCallback) {
+        super(onMessage, (packet: Uint8Array) => {
+            this.writer?.write(packet).catch(this.onError)
+        })
+        this.port = port
+        this.portAvailable = true
+        this.port.addEventListener('disconnect', () => {
+            console.log('shutting down on disconnect')
+            this.port = null
+            this.portAvailable = false
+        })
+    }
+
+    public async openAndLoop() {
+        if (this.port === null) {
+            return
+        }
+        await this.port.open({baudRate: SmartKnobCore.BAUD})
+        if (this.port.readable === null || this.port.writable === null) {
+            throw new Error('Port missing readable or writable!')
+        }
+
+        const reader = this.port.readable.getReader()
+        try {
+            const writer = this.port.writable.getWriter()
+            writer.write(Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0])).catch(this.onError)
+            this.writer = writer
+            try {
+                // eslint-disable-next-line no-constant-condition
+                while (true) {
+                    const {value, done} = await reader.read()
+                    if (done) {
+                        break
+                    }
+                    if (value !== undefined) {
+                        this.onReceivedData(value)
+                    }
+                }
+            } finally {
+                console.log('Releasing writer')
+                writer?.releaseLock()
+            }
+        } finally {
+            console.log('Releasing reader')
+            reader.releaseLock()
+        }
+    }
+
+    private onError(e: unknown) {
+        console.error('Error writing serial', e)
+        this.port?.close()
+        this.port = null
+        this.portAvailable = false
+        this.writer = undefined
+    }
+}

+ 105 - 0
software/js/packages/smartknobjs-webserial/tsconfig.json

@@ -0,0 +1,105 @@
+{
+    "compilerOptions": {
+        /* Visit https://aka.ms/tsconfig.json to read more about this file */
+
+        /* Projects */
+        // "incremental": true,                              /* Enable incremental compilation */
+        // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
+        // "tsBuildInfoFile": "./",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */
+        // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */
+        // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
+        // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
+
+        /* Language and Environment */
+        "target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
+        // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+        // "jsx": "preserve",                                /* Specify what JSX code is generated. */
+        // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
+        // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
+        // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
+        // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+        // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
+        // "reactNamespace": "",                             /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
+        // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
+        // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
+
+        /* Modules */
+        "module": "commonjs" /* Specify what module code is generated. */,
+        // "rootDir": "./",                                  /* Specify the root folder within your source files. */
+        // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
+        // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
+        // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
+        // "paths" :{
+        //   "*": ["./src/typings/*", "./*"]
+        // },
+        // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
+        // "typeRoots": ["src/typings"],                        /* Specify multiple folders that act like `./node_modules/@types`. */
+        // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
+        // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
+        // "resolveJsonModule": true,                        /* Enable importing .json files */
+        // "noResolve": true,                                /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
+
+        /* JavaScript Support */
+        "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
+        // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
+        // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
+
+        /* Emit */
+        "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
+        // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
+        // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
+        // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
+        // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
+        "outDir": "./dist" /* Specify an output folder for all emitted files. */,
+        // "removeComments": true,                           /* Disable emitting comments. */
+        // "noEmit": true,                                   /* Disable emitting files from a compilation. */
+        // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+        // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types */
+        // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+        // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
+        // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
+        // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
+        // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
+        // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+        // "newLine": "crlf",                                /* Set the newline character for emitting files. */
+        // "stripInternal": true,                            /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
+        // "noEmitHelpers": true,                            /* Disable generating custom helper functions like `__extends` in compiled output. */
+        "noEmitOnError": true /* Disable emitting files if any type checking errors are reported. */,
+        // "preserveConstEnums": true,                       /* Disable erasing `const enum` declarations in generated code. */
+        // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
+
+        /* Interop Constraints */
+        // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
+        // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
+        "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
+        // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+        "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
+
+        /* Type Checking */
+        "strict": true /* Enable all strict type-checking options. */,
+        "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied `any` type.. */,
+        "strictNullChecks": true /* When type checking, take into account `null` and `undefined`. */,
+        // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+        // "strictBindCallApply": true,                      /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
+        // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
+        // "noImplicitThis": true,                           /* Enable error reporting when `this` is given the type `any`. */
+        // "useUnknownInCatchVariables": true,               /* Type catch clause variables as 'unknown' instead of 'any'. */
+        // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
+        // "noUnusedLocals": true,                           /* Enable error reporting when a local variables aren't read. */
+        // "noUnussedParameters": true,                       /* Raise an error when a function parameter isn't read */
+        // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
+        "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */,
+        "noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */,
+        // "noUncheckedIndexedAccess": true,                 /* Include 'undefined' in index signature results */
+        "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */,
+        // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type */
+        // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
+        // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
+
+        /* Completeness */
+        // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
+        "skipLibCheck": true /* Skip type checking all .d.ts files. */
+    },
+    "include": ["src"],
+    "exclude": ["node_modules"]
+}

+ 0 - 4
software/js/packages/smartknobjs/src/typings/cobs.d.ts

@@ -1,4 +0,0 @@
-declare module 'cobs' {
-    export function decode(buf: Buffer): Buffer
-    export function encode(buf: Buffer, zeroFrame?: boolean): Buffer
-}

+ 0 - 105
software/js/packages/smartknobjs/tsconfig.json

@@ -1,105 +0,0 @@
-{
-  "compilerOptions": {
-    /* Visit https://aka.ms/tsconfig.json to read more about this file */
-
-    /* Projects */
-    // "incremental": true,                              /* Enable incremental compilation */
-    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
-    // "tsBuildInfoFile": "./",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */
-    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */
-    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
-    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
-
-    /* Language and Environment */
-    "target": "es5",                                     /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
-    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
-    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
-    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
-    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
-    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
-    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
-    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
-    // "reactNamespace": "",                             /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
-    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
-    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
-
-    /* Modules */
-    "module": "commonjs",                                /* Specify what module code is generated. */
-    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
-    // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
-    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
-    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
-    // "paths" :{
-    //   "*": ["./src/typings/*", "./*"]
-    // },
-    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
-    // "typeRoots": ["src/typings"],                        /* Specify multiple folders that act like `./node_modules/@types`. */
-    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
-    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
-    // "resolveJsonModule": true,                        /* Enable importing .json files */
-    // "noResolve": true,                                /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
-
-    /* JavaScript Support */
-    "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
-    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
-    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
-
-    /* Emit */
-    "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
-    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
-    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
-    // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
-    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
-    "outDir": "./dist",                                   /* Specify an output folder for all emitted files. */
-    // "removeComments": true,                           /* Disable emitting comments. */
-    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
-    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
-    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types */
-    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
-    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
-    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
-    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
-    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
-    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
-    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
-    // "stripInternal": true,                            /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
-    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like `__extends` in compiled output. */
-    "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
-    // "preserveConstEnums": true,                       /* Disable erasing `const enum` declarations in generated code. */
-    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
-
-    /* Interop Constraints */
-    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
-    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
-    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
-    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
-    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
-
-    /* Type Checking */
-    "strict": true,                                      /* Enable all strict type-checking options. */
-    "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied `any` type.. */
-    "strictNullChecks": true,                         /* When type checking, take into account `null` and `undefined`. */
-    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
-    // "strictBindCallApply": true,                      /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
-    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
-    // "noImplicitThis": true,                           /* Enable error reporting when `this` is given the type `any`. */
-    // "useUnknownInCatchVariables": true,               /* Type catch clause variables as 'unknown' instead of 'any'. */
-    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
-    // "noUnusedLocals": true,                           /* Enable error reporting when a local variables aren't read. */
-    // "noUnussedParameters": true,                       /* Raise an error when a function parameter isn't read */
-    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
-    "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
-    "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
-    // "noUncheckedIndexedAccess": true,                 /* Include 'undefined' in index signature results */
-    "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
-    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type */
-    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
-    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
-
-    /* Completeness */
-    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
-    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
-  },
-  "include": ["src"],
-  "exclude": ["node_modules"]
-}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 3
software/python/proto_gen/nanopb_pb2.py


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 1
software/python/proto_gen/smartknob_pb2.py


+ 15 - 5
software/python/simple_example.py

@@ -19,14 +19,24 @@ def _run_example():
 
     p = ask_for_serial_port()
     with smartknob_context(p) as s:
+        # Initialize with an empty state object
         last_state = smartknob_pb2.SmartKnobState()
-        def log_state(message):
+
+        # Callback function to handle state updates
+        def log_state(new_state):
             nonlocal last_state
-            if last_state.config.SerializeToString(deterministic=True) != message.config.SerializeToString(deterministic=True):
-                logging.info('State: ' + str(message))
-                last_state = message
+
+            # We'll log the state if it's changed substantially since the last state we recieved
+            config_changed = last_state.config.SerializeToString(deterministic=True) != new_state.config.SerializeToString(deterministic=True)
+            position_changed = last_state.current_position != new_state.current_position
+            sub_position_large_change = abs(last_state.sub_position_unit * last_state.config.position_width_radians - new_state.sub_position_unit * new_state.config.position_width_radians) > math.radians(5)
+            press_nonce_changed = last_state.press_nonce != new_state.press_nonce
+            if config_changed or position_changed or sub_position_large_change or press_nonce_changed:
+                logging.info('State: ' + str(new_state))
+                last_state = new_state
+
+        # Register our state handler function
         s.add_handler('smartknob_state', log_state)
-        s.request_state()
 
         # Run forever, set config when enter is pressed
         while True:

+ 4 - 0
software/python/smartknob_io.py

@@ -34,6 +34,7 @@ sys.path.append(os.path.join(software_root, 'proto_gen'))
 from proto_gen import smartknob_pb2
 
 SMARTKNOB_BAUD = 921600
+PROTOBUF_PROTOCOL_VERSION = 1
 
 
 class Smartknob(object):
@@ -92,6 +93,8 @@ class Smartknob(object):
         message = smartknob_pb2.FromSmartKnob()
         message.ParseFromString(payload)
         self._logger.debug(message)
+        if message.protocol_version != PROTOBUF_PROTOCOL_VERSION:
+            self._logger.warn(f'Invalid protocol version. Expected {PROTOBUF_PROTOCOL_VERSION}, received {message.protocol_version}')
 
         payload_type = message.WhichOneof('payload')
 
@@ -145,6 +148,7 @@ class Smartknob(object):
         nonce = self._next_nonce
         self._next_nonce += 1
 
+        message.protocol_version = PROTOBUF_PROTOCOL_VERSION
         message.nonce = nonce
 
         payload = bytearray(message.SerializeToString())

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels