Compare commits

...

24 Commits

Author SHA1 Message Date
Scott Bezek
4eb988399c
Update README.md
link to quick video playback control demo video
2024-01-22 00:05:19 -08:00
Grigorii Merkushev
8700af8a24
Fine tuning for esp32s3 (#143)
- exposed strain SCK and DO
- use 12 bit PWM (instead of 16 bit) for screen brightness on s3
- added option to use uart serial for s3
- bump nanopb/Nanopb @ 0.4.7
- fixed SPI bus alias and DMA channel for s3

---------

Co-authored-by: Scott Bezek <scottbez1@gmail.com>
2023-11-13 22:42:44 -08:00
Scott Bezek
370b8ca92b BOM - Replace discontinued C2922480 with C15850
Fixes #139 - thanks @lorhof1 and @Wilgelmy
2023-10-28 15:50:58 -07:00
Scott Bezek
6d946c49e5 Handle RequestState messages; update python example 2023-10-28 15:42:28 -07:00
VIPQualityPost
dafc5ca1e2
peg sfoc version (#141)
nanofoc simpleFOC version peg at 2.3.0

Fixes #140
2023-10-18 10:54:03 -07:00
Scott Bezek
406dbb08ec Minor README updates 2023-09-10 17:50:10 -07:00
Scott Bezek
2eddc13528 Add a bunch of proto documentation 2023-09-10 17:25:23 -07:00
Scott Bezek
590ba8850c
Demo improvements (#135)
- LED hue config
- press state reporting
- custom display modes for timeline demo
2023-09-10 15:44:00 -07:00
Scott Bezek
3cff3533d7
Update README.md 2023-07-29 15:50:09 -07:00
Scott Bezek
1e2b1983c2 Fix GitHub Actions path filters w/ globs 2023-07-09 17:40:41 -07:00
Scott Bezek
d1495a8334 Strain calibration 2023-07-09 17:25:18 -07:00
Scott Bezek
1d2458b28a
Web Serial library and demo (#125)
- reworked smartknobjs into separate smartknobjs-node and
smartknobjs-webserial packages
- configured CI to build and deploy webserial example to
https://scottbez1.github.io/smartknob/
2023-06-20 00:13:25 -07:00
Scott Bezek
948297fa82
Implement persistent configuration/calibration storage using FFAT (#124)
Reintroduces persistent configuration from #122, but using FFAT instead
of SPIFFS, since SPIFFS had bad performance impacts on the motor control
loop.
2023-06-19 13:13:08 -07:00
Scott Bezek
0ebbcc8c23 Revert "SPIFFS persistent storage for motor calibration (#122)"
Seems to be causing weird behavior/performance with the motor control loop, particularly noticeable with fine detents. Reverting for now until I have a chance to investigate further.

This reverts commit 1d73af17cc.
2023-06-17 10:54:55 -07:00
Anders Einar Hilden
231b75eca9
Add more info on printed parts to README.md (#123)
Co-authored-by: Scott Bezek <scottbez1@gmail.com>
2023-06-17 10:49:36 -07:00
Scott Bezek
d64cc55432 Add missing partition file and update GH actions to run nanofoc build 2023-06-16 09:21:13 -07:00
Scott Bezek
1d73af17cc
SPIFFS persistent storage for motor calibration (#122)
- New Configuration class for managing persistent configuration
- New proto messages for serialized configuration, with flexibility for
expansion
- Load persistent configuration at startup, if available, use for
default motor calibration
- Save motor calibration to persistent configuration file
2023-06-14 22:43:20 -07:00
Scott Bezek
2afc38f7f9
Updated config schema, improved demo (#121)
- Breaking proto updates
- added position nonce to config, and added ability to specify a
sub_position via config as well => more flexibility to control position
via config
- added version as first field of serial protocol messages, so future
breaking proto updates can easily be detected and ignored
- Fixed up web-based demo of a mock video editor/timeline jog/playback
controller
- Fixed config/state flow so that mode changes correctly maintain
playback position
  - Demo: https://www.youtube.com/watch?v=J9192DfZplk
2023-06-13 22:45:49 -07:00
Scott Bezek
08146e292d Add some basic bounds checking during calibration 2023-06-13 22:29:40 -07:00
Baku 麦
3fdd9fa601
Use TFT_eSprite.drawNumber for the current position (#118)
I would suggest using `drawNumber` instead of drawString to align each
digit of the number to the same width and erase the jittering effect
when the current position is changed.
2023-06-13 22:03:57 -07:00
Scott Bezek
9903ac63e6
NanoFOC/ESP32-S3 support (#117)
- New PlatformIO env (`nanofoc`) for the NanoFOC hardware
  - Huge thanks to @lgc00 for the initial NanoFOC configuration and prototyping!
  - Uses `HWCDC` rather than `UartStream` as the serial `Stream` interface (ESP32-S3 specific change)
  - Support for MAQ430 magnetic encoder sensor
  - Motor-specific PID tuning constants moved to separate header files

---------

Co-authored-by: lgc00 <lgcasd1+1@gmail.com>
2023-05-29 17:17:55 -07:00
Scott Bezek
4a1d2bee77
Update README.md
Link to new firmware/software guide, other cleanup/updates.
2023-02-10 23:44:14 -08:00
Scott Bezek
acfc85f72b
Python software example (#106)
Add protobuf/cobs/crc32 framework code and a really basic python example
2023-02-04 12:34:12 -08:00
Scott Bezek
9431ed3971
New detent configurations (magnetic and biased-to-center) and demo React app (#105)
Breaking change to protobuf messages to support new detent configurations - adds more flexibility compared to existing config parameters (e.g. min/max position rather than just number of positions).

New magnetic detent mode allows for config to specify up to 5 nearest detent positions (with smooth scrolling in between them); the intent is a higher-level controller (e.g. connected demo app, see below) to dynamically update that list as the position changes, in order to support an unlimited number of magnetic detents.

---

React demo app implements a mock video editor timeline, with the ability to interact with it via the SmartKnob connected over USB serial. (Currently uses a node backend to stream config/state to the frontend via websockets, but it would probably be possible to do everything on the frontend with webserial in the future?).

The 3 demo input modes are:
- Scroll: quickly smooth-scroll through the timeline, with magnetic detents at clip boundaries. Scroll speed is determined by zoom level (currently only controllable by mouse scroll wheel)
- Frames: small detents (1.5 degrees) to move frame-by-frame through video
- Speed/playback: "spring-loaded" speed control, biased to return to center (paused), with detents at powers of 2: 1x, 2x, 4x playback

Currently the frontend app has some bugs, particularly with the current playback position when switching modes and zooming, but planning to merge anyway to get the other breaking changes into master along with the initial demo framework.
2023-01-16 13:58:03 -08:00
97 changed files with 33788 additions and 4830 deletions

View File

@ -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
.github/workflows/js.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

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

109
README.md
View File

@ -12,27 +12,27 @@ Join the [Discord community](https://discord.gg/5jyhjcbTnR) to discuss the proje
# Designs
## SmartKnob View
Premium SmartKnob experience. Under active development.
The "SmartKnob View" is the premium SmartKnob experience with an integrated display shown in my [demo video](https://www.youtube.com/watch?v=ip641WmY4pA). Under active development.
🎉 **Motors are [now available](https://www.sparkfun.com/products/20441)!** If you've been following this project,
you'll know that the recommended motors went out of stock nearly immediately after it was published.
Thanks to [the community](https://github.com/scottbez1/smartknob/issues/16#issuecomment-1094482805%5D), we were able to
identify the likely original manufacturer, and recently SparkFun Electronics funded a new production run and are now
[selling them](https://www.sparkfun.com/products/20441)! Thanks to everyone who helped search and investigate different
identify the likely original manufacturer, and recently SparkFun Electronics has been getting them produced and regularly
[stocking them](https://www.sparkfun.com/products/20441)! (However, they've been selling out quickly each time they restock, so definitely sign up for backorder notifications if they're out of stock when you check). Thanks to everyone who helped search and investigate different
motor options along the way!
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,25 +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.
- integrate nanopb for structured serial data (see [splitflap protobuf protocol](https://github.com/scottbez1/splitflap/blob/1440aba54d5b0d822ec5da68762431879988d7ef/arduino/splitflap/esp32/splitflap/serial_proto_protocol.cpp) for example)
- 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
@ -119,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
@ -138,28 +152,70 @@ 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
## SmartKnob Mini
Planned for the future.
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.
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 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 1 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. Certainly less than $200 in parts, and maybe closer to $100?
I can't stop you, but I will caution that **you probably won't be super happy with it** depending on your intended application.
**Does it work with XYZ?**
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.
Not yet. 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!
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.
**Can I buy one as a kit or already assembled?**
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?
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?
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.
It's open source with a fairly permissive license though, so in theory anyone could start offering kits/assemblies. If someone does go down that route of selling them, note that attribution is
_required_ (and I wouldn't say no to [royalties/tips/thanks](https://github.com/sponsors/scottbez1/) if you're in a giving mood 🙂).
# Firmware and Software
More extensive documentation of the firmware and software (and how to get started) can be found in the dedicated [SmartKnob Firmware and Software Guide](https://paper.dropbox.com/doc/SmartKnob-firmware-and-software--Byho6npe9XvZLZLxJ_8bK5TqAg-VUb9nq7btuhnHw5KlgJIH#:h2=Calibration)
## General Component Info
@ -178,12 +234,12 @@ Excellent sensor at a reasonable price - highly recommended. Less noisy than TLV
[Ordering (LCSC)](https://lcsc.com/product-detail/Angle-Linear-Position-Sensors_Magn-Tek-MT6701CT-STD_C2856764.html)
#### TLV493D (Infineon)
A mediocre choice. Easy to prototype with using [Adafruit's QWIIC breakout board](https://www.adafruit.com/product/4366).
Not used in the SmartKnob view, but a common/popular magnetic encoder in general. It's a mediocre choice for a haptic feedback implementation. Easy to prototype with using [Adafruit's QWIIC breakout board](https://www.adafruit.com/product/4366).
In my testing, it is a little noisy, requiring filtering/smoothing that can slow responsiveness, hurting control loop stability. Or, with less filtering, the noise
In my testing, it's noisy, requiring filtering/smoothing that can slow responsiveness, hurting control loop stability. Or, with less filtering, the noise
can easily be "amplified" by the derivative component in the PID motor torque controller, causing audible (and tactile) humming/buzzing.
There is also apparently a known silicon issue that causes the internal ADC to sometimes completely lock up, requiring a full reset and re-configuration. See section
But the bigger issue is that there is apparently a known silicon issue that causes the internal ADC to sometimes completely lock up, requiring a full reset and re-configuration, which can cause delays/gaps in data! See section
5.6 in the [User Manual](https://www.infineon.com/dgdl/Infineon-TLV493D-A1B6_3DMagnetic-UM-v01_03-EN.pdf?fileId=5546d46261d5e6820161e75721903ddd)
In the Master Controlled Mode (MCM) or the Fast Mode (FM) the ADC conversion may hang up. A hang up can
@ -232,11 +288,6 @@ This is overall the easiest motor to get started with. Low cogging and a built-i
Available [from SparkFun](https://www.sparkfun.com/products/20441)!
# Firmware
TODO: document this
Also TODO: implement a lot more of the firmware
# Acknowledgements
This project was greatly inspired by Jesse Schoch's video "[haptic textures and virtual detents](https://www.youtube.com/watch?v=1gPQfDkX3BU)" and the
corresponding [discussion in the SimpleFOC community](https://community.simplefoc.com/t/haptic-textures/301). Seriously, this project wouldn't exist if not for that video - thank you Jesse!

View File

@ -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)

View File

@ -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,
1 # ESP-IDF Partition Table
2 # Name, Type, SubType, Offset, Size, Flags
3 # bootloader.bin,, 0x1000, 32K
4 # partition table, 0x8000, 4K
5 nvs, data, nvs, 0x9000, 20K,
6 otadata, data, ota, 0xe000, 8K,
7 ota_0, 0, ota_0, 0x10000, 1408K,
8 ota_1, 0, ota_1, 0x170000, 1408K,
9 uf2, app, factory,0x2d0000, 256K,
10 ffat, data, fat, 0x310000, 960K,

View File

@ -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);
}
}

View File

@ -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_;
};

View File

@ -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(0);
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);
@ -54,71 +64,125 @@ void DisplayTask::run() {
}
spr_.fillSprite(TFT_BLACK);
if (state.config.num_positions > 1) {
int32_t height = state.current_position * TFT_HEIGHT / (state.config.num_positions - 1);
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 (state.config.num_positions > 0) {
float range_radians = (state.config.num_positions - 1) * 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);
}
int32_t num_positions = state.config.max_position - state.config.min_position + 1;
float adjusted_sub_position = state.sub_position_unit * state.config.position_width_radians;
if (state.config.num_positions > 0) {
if (state.current_position == 0 && state.sub_position_unit < 0) {
if (num_positions > 0) {
if (state.current_position == state.config.min_position && state.sub_position_unit < 0) {
adjusted_sub_position = -logf(1 - state.sub_position_unit * state.config.position_width_radians / 5 / PI * 180) * 5 * PI / 180;
} else if (state.current_position == state.config.num_positions - 1 && state.sub_position_unit > 0) {
} else if (state.current_position == state.config.max_position && state.sub_position_unit > 0) {
adjusted_sub_position = logf(1 + state.sub_position_unit * state.config.position_width_radians / 5 / PI * 180) * 5 * PI / 180;
}
}
float raw_angle = left_bound - state.current_position * state.config.position_width_radians;
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 (state.config.num_positions > 0 && ((state.current_position == 0 && state.sub_position_unit < 0) || (state.current_position == state.config.num_positions - 1 && 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;
}
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 {
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), 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);
@ -127,7 +191,7 @@ void DisplayTask::run() {
SemaphoreGuard lock(mutex_);
ledcWrite(LEDC_CHANNEL_LCD_BACKLIGHT, brightness_);
}
delay(2);
delay(5);
}
}
@ -137,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) {

View File

@ -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;

View File

@ -11,10 +11,9 @@
#endif
#include "interface_task.h"
#include "semaphore_guard.h"
#include "util.h"
#define COUNT_OF(A) (sizeof(A) / sizeof(A[0]))
#if SK_LEDS
CRGB leds[NUM_LEDS];
#endif
@ -28,113 +27,227 @@ Adafruit_VEML7700 veml = Adafruit_VEML7700();
#endif
static PB_SmartKnobConfig configs[] = {
// int32_t num_positions;
// int32_t position;
// float sub_position_unit;
// uint8_t position_nonce;
// int32_t min_position;
// int32_t max_position;
// float position_width_radians;
// float detent_strength_unit;
// float endstop_strength_unit;
// float snap_point;
// char text[51];
// 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
10 * PI / 180,
0,
1,
1.1,
"Unbounded\nNo detents",
0,
{},
0,
200,
},
{
11,
0,
0,
1,
0,
10,
10 * PI / 180,
0,
1,
1.1,
"Bounded 0-10\nNo detents",
0,
{},
0,
0,
},
{
73,
0,
0,
2,
0,
72,
10 * PI / 180,
0,
1,
1.1,
"Multi-rev\nNo detents",
0,
{},
0,
73,
},
{
2,
0,
0,
3,
0,
1,
60 * PI / 180,
1,
1,
0.55, // Note the snap point is slightly past the midpoint (0.5); compare to normal detents which use a snap point *past* the next value (i.e. > 1)
"On/off\nStrong detent",
0,
{},
0,
157,
},
{
1,
0,
0,
4,
0,
0,
60 * PI / 180,
0.01,
0.6,
1.1,
"Return-to-center",
0,
{},
0,
45,
},
{
256,
127,
0,
5,
0,
255,
1 * PI / 180,
0,
1,
1.1,
"Fine values\nNo detents",
0,
{},
0,
219,
},
{
256,
127,
0,
5,
0,
255,
1 * PI / 180,
1,
1,
1.1,
"Fine values\nWith detents",
0,
{},
0,
25,
},
{
32,
0,
0,
6,
0,
31,
8.225806452 * PI / 180,
2,
1,
1.1,
"Coarse values\nStrong detents",
0,
{},
0,
200,
},
{
32,
0,
0,
6,
0,
31,
8.225806452 * PI / 180,
0.2,
1,
1.1,
"Coarse values\nWeak detents",
0,
{},
0,
0,
},
{
0,
0,
7,
0,
31,
7 * PI / 180,
2.5,
1,
0.7,
"Magnetic detents",
4,
{2, 10, 21, 22},
0,
73,
},
{
0,
0,
8,
-6,
6,
60 * PI / 180,
1,
1,
0.55,
"Return-to-center\nwith detents",
0,
{},
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() {
@ -149,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
@ -161,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");
@ -190,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);
}
}
@ -228,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() {
@ -244,40 +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();
}
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);
// 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;
if (!pressed && press_value_unit > 0.75) {
motor_task_.playHaptic(true);
pressed = true;
changeConfig(true);
} else if (pressed && press_value_unit < 0.25) {
motor_task_.playHaptic(false);
pressed = false;
// 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 {
@ -304,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);
@ -314,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);
}

View File

@ -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);
};

View File

@ -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);

View File

@ -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
};

View File

@ -3,22 +3,15 @@
#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 "motors/motor_config.h"
#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 = 2.77;
static const Direction FOC_DIRECTION = Direction::CW;
static const int MOTOR_POLE_PAIRS = 7;
// ####
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
@ -85,18 +87,21 @@ void MotorTask::run() {
float current_detent_center = motor.shaft_angle;
PB_SmartKnobConfig config = {
.num_positions = 2,
.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;
uint32_t last_publish = 0;
PB_SmartKnobConfig latest_config = config;
while (1) {
motor.loopFOC();
@ -108,14 +113,62 @@ void MotorTask::run() {
calibrate();
break;
case CommandType::CONFIG: {
// Check new config for validity
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 (new_config.endstop_strength_unit < 0) {
log("Ignoring invalid config: endstop_strength_unit cannot be negative");
break;
}
if (new_config.snap_point < 0.5) {
log("Ignoring invalid config: snap_point must be >= 0.5 for stability");
break;
}
if (new_config.detent_positions_count > COUNT_OF(new_config.detent_positions)) {
log("Ignoring invalid config: detent_positions_count is too large");
break;
}
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
config = command.data.config;
latest_config = config;
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 (position_updated || new_config.position_width_radians != config.position_width_radians) {
log("adjusting detent center");
float new_sub_position = position_updated ? new_config.sub_position_unit : latest_sub_position_unit;
#if SK_INVERT_ROTATION
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 = new_config;
log("Got new config");
current_detent_center = motor.shaft_angle;
#if SK_INVERT_ROTATION
current_detent_center = -motor.shaft_angle;
#endif
// Update derivative factor of torque controller based on detent width.
// If the D factor is large on coarse detents, the motor ends up making noise because the P&D factors amplify the noise from the sensor.
@ -130,7 +183,9 @@ void MotorTask::run() {
const float derivative_position_width_lower = radians(3);
const float derivative_position_width_upper = radians(8);
const float raw = derivative_lower_strength + (derivative_upper_strength - derivative_lower_strength)/(derivative_position_width_upper - derivative_position_width_lower)*(config.position_width_radians - derivative_position_width_lower);
motor.PID_velocity.D = CLAMP(
// When there are intermittent detents (set via detent_positions), disable derivative factor as this adds extra "clicks" when nearing
// a detent.
motor.PID_velocity.D = config.detent_positions_count > 0 ? 0 : CLAMP(
raw,
min(derivative_lower_strength, derivative_upper_strength),
max(derivative_lower_strength, derivative_upper_strength)
@ -175,22 +230,31 @@ void MotorTask::run() {
#if SK_INVERT_ROTATION
angle_to_detent_center = -motor.shaft_angle - current_detent_center;
#endif
if (angle_to_detent_center > config.position_width_radians * config.snap_point && (config.num_positions <= 0 || config.position > 0)) {
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 + (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 || 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 < -config.position_width_radians * config.snap_point && (config.num_positions <= 0 || config.position < config.num_positions - 1)) {
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 = config.num_positions > 0 && ((angle_to_detent_center > 0 && config.position == 0) || (angle_to_detent_center < 0 && config.position == config.num_positions - 1));
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;
@ -200,7 +264,20 @@ void MotorTask::run() {
// Don't apply torque if velocity is too high (helps avoid positive feedback loop/runaway)
motor.move(0);
} else {
float torque = motor.PID_velocity(-angle_to_detent_center + dead_zone_adjustment);
float input = -angle_to_detent_center + dead_zone_adjustment;
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] == current_position) {
in_detent = true;
break;
}
}
if (!in_detent) {
input = 0;
}
}
float torque = motor.PID_velocity(input);
#if SK_INVERT_ROTATION
torque = -torque;
#endif
@ -210,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);
}
}
@ -275,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;
@ -282,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();
@ -308,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) {
@ -318,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
@ -325,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;
@ -366,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_);
@ -375,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;
@ -422,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) {
@ -438,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() {

View File

@ -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_;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);

View File

@ -1,5 +1,5 @@
/* Automatically generated nanopb constant definitions */
/* Generated by nanopb-0.4.7-dev */
/* Generated by nanopb-0.4.7 */
#include "smartknob.pb.h"
#if PB_PROTO_HEADER_VERSION != 40
@ -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)

View File

@ -1,5 +1,5 @@
/* Automatically generated nanopb header */
/* Generated by nanopb-0.4.7-dev */
/* Generated by nanopb-0.4.7 */
#ifndef PB_PB_SMARTKNOB_PB_H_INCLUDED
#define PB_PB_SMARTKNOB_PB_H_INCLUDED
@ -10,10 +10,7 @@
#endif
/* Struct definitions */
typedef struct _PB_RequestState {
char dummy_field;
} PB_RequestState;
/* * Lets the host know that a ToSmartknob message was received and should not be retried. */
typedef struct _PB_Ack {
uint32_t nonce;
} PB_Ack;
@ -23,34 +20,142 @@ typedef struct _PB_Log {
} PB_Log;
typedef struct _PB_SmartKnobConfig {
int32_t num_positions;
/* *
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 TO the Smartknob from the USB host */
typedef struct _PB_ToSmartknob {
uint32_t nonce;
pb_size_t which_payload;
union {
PB_RequestState request_state;
PB_SmartKnobConfig smartknob_config;
} payload;
} PB_ToSmartknob;
/* 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;
@ -59,58 +164,128 @@ typedef struct _PB_FromSmartKnob {
} payload;
} PB_FromSmartKnob;
typedef struct _PB_RequestState {
char dummy_field;
} PB_RequestState;
/* Message TO the Smartknob from the host */
typedef struct _PB_ToSmartknob {
uint8_t protocol_version;
uint32_t nonce;
pb_size_t which_payload;
union {
PB_RequestState request_state;
PB_SmartKnobConfig smartknob_config;
} 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, ""}
#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, ""}
#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_num_positions_tag 1
#define PB_SmartKnobConfig_position_tag 2
#define PB_SmartKnobConfig_position_width_radians_tag 3
#define PB_SmartKnobConfig_detent_strength_unit_tag 4
#define PB_SmartKnobConfig_endstop_strength_unit_tag 5
#define PB_SmartKnobConfig_snap_point_tag 6
#define PB_SmartKnobConfig_text_tag 7
#define PB_SmartKnobConfig_position_tag 1
#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_ToSmartknob_nonce_tag 1
#define PB_ToSmartknob_request_state_tag 2
#define PB_ToSmartknob_smartknob_config_tag 3
#define PB_FromSmartKnob_ack_tag 1
#define PB_FromSmartKnob_log_tag 2
#define PB_FromSmartKnob_smartknob_state_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
@ -124,28 +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, num_positions, 1) \
X(a, STATIC, SINGULAR, INT32, position, 2) \
X(a, STATIC, SINGULAR, FLOAT, position_width_radians, 3) \
X(a, STATIC, SINGULAR, FLOAT, detent_strength_unit, 4) \
X(a, STATIC, SINGULAR, FLOAT, endstop_strength_unit, 5) \
X(a, STATIC, SINGULAR, FLOAT, snap_point, 6) \
X(a, STATIC, SINGULAR, STRING, text, 7)
X(a, STATIC, SINGULAR, INT32, position, 1) \
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
@ -154,31 +327,63 @@ X(a, STATIC, SINGULAR, STRING, text, 7)
#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 94
#define PB_SmartKnobState_size 112
#define PB_ToSmartknob_size 102
#define PB_SmartKnobConfig_size 184
#define PB_SmartKnobState_size 206
#define PB_StrainCalibration_size 22
#define PB_ToSmartknob_size 196
#ifdef __cplusplus
} /* extern "C" */

View File

@ -2,14 +2,22 @@
#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.num_positions == second.num_positions
&& 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
&& strcmp(first.text, second.text) == 0;
&& 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]))
&& first.snap_point_bias == second.snap_point_bias;
}
bool state_eq(PB_SmartKnobState& first, PB_SmartKnobState& second) {

View File

@ -6,11 +6,18 @@ void SerialProtocolPlaintext::handleState(const PB_SmartKnobState& state) {
bool substantial_change = (latest_state_.current_position != state.current_position)
|| (latest_state_.config.detent_strength_unit != state.config.detent_strength_unit)
|| (latest_state_.config.endstop_strength_unit != state.config.endstop_strength_unit)
|| (latest_state_.config.num_positions != state.config.num_positions);
|| (latest_state_.config.min_position != state.config.min_position)
|| (latest_state_.config.max_position != state.config.max_position);
latest_state_ = state;
if (substantial_change) {
stream_.printf("STATE: %d/%d (detent strength: %0.2f, width: %0.0f deg, endstop strength: %0.2f)\n", state.current_position, state.config.num_positions - 1, state.config.detent_strength_unit, degrees(state.config.position_width_radians), state.config.endstop_strength_unit);
stream_.printf("STATE: %d [%d, %d] (detent strength: %0.2f, width: %0.0f deg, endstop strength: %0.2f)\n",
state.current_position,
state.config.min_position,
state.config.max_position,
state.config.detent_strength_unit,
degrees(state.config.position_width_radians),
state.config.endstop_strength_unit);
}
}
@ -33,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.");
}

View File

@ -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_;
};

View File

@ -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();

View File

@ -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
firmware/src/util.cpp Normal file
View File

@ -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;
}

View File

@ -5,3 +5,11 @@ template <typename T> T CLAMP(const T& value, const T& low, const T& high)
{
return value < low ? low : (value > high ? high : value);
}
#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));
}

View File

@ -24,40 +24,53 @@ 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}
; Display enabled: 1=enable, 0=disable
-DSK_DISPLAY=1
; 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
-DSENSOR_MT6701=1
; Strain-gauge press input enabled: 1=enable, 0=disable
-DSK_STRAIN=1
-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
-DPIN_UL=25
-DPIN_VH=27
@ -73,12 +86,17 @@ 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
-DCGRAM_OFFSET=1
@ -98,11 +116,178 @@ 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
; (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

13
proto/Pipfile Normal file
View File

@ -0,0 +1,13 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
grpcio-tools = "*"
protobuf = "==3.20.3"
[dev-packages]
[requires]
python_version = "3.10"

160
proto/Pipfile.lock generated Normal file
View File

@ -0,0 +1,160 @@
{
"_meta": {
"hash": {
"sha256": "81cf165543dd8620d62e1e5c86575e5245b7b76980809b0fbd484de2aba002cb"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.10"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"grpcio": {
"hashes": [
"sha256:094e64236253590d9d4075665c77b329d707b6fca864dd62b144255e199b4f87",
"sha256:0dc5354e38e5adf2498312f7241b14c7ce3484eefa0082db4297189dcbe272e6",
"sha256:0e1a9e1b4a23808f1132aa35f968cd8e659f60af3ffd6fb00bcf9a65e7db279f",
"sha256:0fb93051331acbb75b49a2a0fd9239c6ba9528f6bdc1dd400ad1cb66cf864292",
"sha256:16c71740640ba3a882f50b01bf58154681d44b51f09a5728180a8fdc66c67bd5",
"sha256:172405ca6bdfedd6054c74c62085946e45ad4d9cec9f3c42b4c9a02546c4c7e9",
"sha256:17ec9b13cec4a286b9e606b48191e560ca2f3bbdf3986f91e480a95d1582e1a7",
"sha256:22b011674090594f1f3245960ced7386f6af35485a38901f8afee8ad01541dbd",
"sha256:24ac1154c4b2ab4a0c5326a76161547e70664cd2c39ba75f00fc8a2170964ea2",
"sha256:257478300735ce3c98d65a930bbda3db172bd4e00968ba743e6a1154ea6edf10",
"sha256:29cb97d41a4ead83b7bcad23bdb25bdd170b1e2cba16db6d3acbb090bc2de43c",
"sha256:2b170eaf51518275c9b6b22ccb59450537c5a8555326fd96ff7391b5dd75303c",
"sha256:31bb6bc7ff145e2771c9baf612f4b9ebbc9605ccdc5f3ff3d5553de7fc0e0d79",
"sha256:3c2b3842dcf870912da31a503454a33a697392f60c5e2697c91d133130c2c85d",
"sha256:3f9b0023c2c92bebd1be72cdfca23004ea748be1813a66d684d49d67d836adde",
"sha256:471d39d3370ca923a316d49c8aac66356cea708a11e647e3bdc3d0b5de4f0a40",
"sha256:49d680356a975d9c66a678eb2dde192d5dc427a7994fb977363634e781614f7c",
"sha256:4c4423ea38a7825b8fed8934d6d9aeebdf646c97e3c608c3b0bcf23616f33877",
"sha256:506b9b7a4cede87d7219bfb31014d7b471cfc77157da9e820a737ec1ea4b0663",
"sha256:538d981818e49b6ed1e9c8d5e5adf29f71c4e334e7d459bf47e9b7abb3c30e09",
"sha256:59dffade859f157bcc55243714d57b286da6ae16469bf1ac0614d281b5f49b67",
"sha256:5a6ebcdef0ef12005d56d38be30f5156d1cb3373b52e96f147f4a24b0ddb3a9d",
"sha256:5dca372268c6ab6372d37d6b9f9343e7e5b4bc09779f819f9470cd88b2ece3c3",
"sha256:6df3b63538c362312bc5fa95fb965069c65c3ea91d7ce78ad9c47cab57226f54",
"sha256:6f0b89967ee11f2b654c23b27086d88ad7bf08c0b3c2a280362f28c3698b2896",
"sha256:75e29a90dc319f0ad4d87ba6d20083615a00d8276b51512e04ad7452b5c23b04",
"sha256:7942b32a291421460d6a07883033e392167d30724aa84987e6956cd15f1a21b9",
"sha256:9235dcd5144a83f9ca6f431bd0eccc46b90e2c22fe27b7f7d77cabb2fb515595",
"sha256:97d67983189e2e45550eac194d6234fc38b8c3b5396c153821f2d906ed46e0ce",
"sha256:9ff42c5620b4e4530609e11afefa4a62ca91fa0abb045a8957e509ef84e54d30",
"sha256:a8a0b77e992c64880e6efbe0086fe54dfc0bbd56f72a92d9e48264dcd2a3db98",
"sha256:aacb54f7789ede5cbf1d007637f792d3e87f1c9841f57dd51abf89337d1b8472",
"sha256:bc59f7ba87972ab236f8669d8ca7400f02a0eadf273ca00e02af64d588046f02",
"sha256:cc2bece1737b44d878cc1510ea04469a8073dbbcdd762175168937ae4742dfb3",
"sha256:cd3baccea2bc5c38aeb14e5b00167bd4e2373a373a5e4d8d850bd193edad150c",
"sha256:dad6533411d033b77f5369eafe87af8583178efd4039c41d7515d3336c53b4f1",
"sha256:e223a9793522680beae44671b9ed8f6d25bbe5ddf8887e66aebad5e0686049ef",
"sha256:e473525c28251558337b5c1ad3fa969511e42304524a4e404065e165b084c9e4",
"sha256:e4ef09f8997c4be5f3504cefa6b5c6cc3cf648274ce3cede84d4342a35d76db6",
"sha256:e6dfc2b6567b1c261739b43d9c59d201c1b89e017afd9e684d85aa7a186c9f7a",
"sha256:eacad297ea60c72dd280d3353d93fb1dcca952ec11de6bb3c49d12a572ba31dd",
"sha256:f1158bccbb919da42544a4d3af5d9296a3358539ffa01018307337365a9a0c64",
"sha256:f1fec3abaf274cdb85bf3878167cfde5ad4a4d97c68421afda95174de85ba813",
"sha256:f96ace1540223f26fbe7c4ebbf8a98e3929a6aa0290c8033d12526847b291c0f",
"sha256:fbdbe9a849854fe484c00823f45b7baab159bdd4a46075302281998cb8719df5"
],
"markers": "python_version >= '3.7'",
"version": "==1.51.1"
},
"grpcio-tools": {
"hashes": [
"sha256:0119aabd9ceedfdf41b56b9fdc8284dd85a7f589d087f2694d743f346a368556",
"sha256:072234859f6069dc43a6be8ad6b7d682f4ba1dc2e2db2ebf5c75f62eee0f6dfb",
"sha256:0fb6c1c1e56eb26b224adc028a4204b6ad0f8b292efa28067dff273bbc8b27c4",
"sha256:189be2a9b672300ca6845d94016bdacc052fdbe9d1ae9e85344425efae2ff8ef",
"sha256:21ff50e321736eba22210bf9b94e05391a9ac345f26e7df16333dc75d63e74fb",
"sha256:3c8749dca04a8d302862ceeb1dfbdd071ee13b281395975f24405a347e5baa57",
"sha256:4fa4300b1be59b046492ed3c5fdb59760bc6433f44c08f50de900f9552ec7461",
"sha256:516eedd5eb7af6326050bc2cfceb3a977b9cc1144f283c43cc4956905285c912",
"sha256:51be91b7c7056ff9ee48b1eccd4a2840b0126230803a5e09dfc082a5b16a91c1",
"sha256:5410d6b601d1404835e34466bd8aee37213489b36ee1aad2276366e265ff29d4",
"sha256:55fdebc73fb580717656b1bafa4f8eca448726a7aa22726a6c0a7895d2f0f088",
"sha256:6cc298fbfe584de8876a85355efbcf796dfbcfac5948c9560f5df82e79336e2a",
"sha256:6d9753944e5a6b6b78b76ce9d2ae0fe3f748008c1849deb7fadcb64489d6553b",
"sha256:70564521e86a0de35ea9ac6daecff10cb46860aec469af65869974807ce8e98b",
"sha256:7307dd2408b82ea545ae63502ec03036b025f449568556ea9a056e06129a7a4e",
"sha256:80f450272316ca0924545f488c8492649ca3aeb7044d4bf59c426dcdee527f7c",
"sha256:84a84d601a238572d049d3108e04fe4c206536e81076d56e623bd525a1b38def",
"sha256:8588819b22d0de3aa1951e1991cc3e4b9aa105eecf6e3e24eb0a2fc8ab958b3e",
"sha256:8902a035708555cddbd61b5467cea127484362decc52de03f061a1a520fe90cd",
"sha256:8a5614251c46da07549e24f417cf989710250385e9d80deeafc53a0ee7df6325",
"sha256:8e0d74403484eb77e8df2566a64b8b0b484b5c87903678c381634dd72f252d5e",
"sha256:92acc3e10ba2b0dcb90a88ae9fe1cc0ffba6868545207e4ff20ca95284f8e3c9",
"sha256:9443f5c30bac449237c3cf99da125f8d6e6c01e17972bc683ee73b75dea95573",
"sha256:9771d4d317dca029dfaca7ec9282d8afe731c18bc536ece37fd39b8a974cc331",
"sha256:a415fbec67d4ff7efe88794cbe00cf548d0f0a5484cceffe0a0c89d47694c491",
"sha256:a43d26714933f23de93ea0bf9c86c66a6ede709b8ca32e357f9e2181703e64ae",
"sha256:ace0035766fe01a1b096aa050be9f0a9f98402317e7aeff8bfe55349be32a407",
"sha256:ae56f133b05b7e5d780ef7e032dd762adad7f3dc8f64adb43ff5bfabd659f435",
"sha256:bdbbe63f6190187de5946891941629912ac8196701ed2253fa91624a397822ec",
"sha256:cabc8b0905cedbc3b2b7b2856334fa35cce3d4bc79ae241cacd8cca8940a5c85",
"sha256:cb75bac0cd43858cb759ef103fe68f8c540cb58b63dda127e710228fec3007b8",
"sha256:d18599ab572b2f15a8f3db49503272d1bb4fcabb4b4d1214ef03aca1816b20a0",
"sha256:d18ef2adc05a8ef9e58ac46357f6d4ce7e43e077c7eda0a4425773461f9d0e6e",
"sha256:d598ccde6338b2cfbb3124f34c95f03394209013f9b1ed4a5360a736853b1c27",
"sha256:d77e8b1613876e0d8fd17709509d4ceba13492816426bd156f7e88a4c47e7158",
"sha256:d886a9e052a038642b3af5d18e6f2085d1656d9788e202dc23258cf3a751e7ca",
"sha256:d96e96ae7361aa51c9cd9c73b677b51f691f98df6086860fcc3c45852d96b0b0",
"sha256:dcaaecdd5e847de5c1d533ea91522bf56c9e6b2dc98cdc0d45f0a1c26e846ea2",
"sha256:e0403e095b343431195db1305248b50019ad55d3dd310254431af87e14ef83a2",
"sha256:e20d7885a40e68a2bda92908acbabcdf3c14dd386c3845de73ba139e9df1f132",
"sha256:e5bb396d63495667d4df42e506eed9d74fc9a51c99c173c04395fe7604c848f1",
"sha256:e712a6d00606ad19abdeae852a7e521d6f6d0dcea843708fecf3a38be16a851e",
"sha256:e7e7668f89fd598c5469bb58e16bfd12b511d9947ccc75aec94da31f62bc3758",
"sha256:f0feb4f2b777fa6377e977faa89c26359d4f31953de15e035505b92f41aa6906",
"sha256:f75973a42c710999acd419968bc79f00327e03e855bbe82c6529e003e49af660",
"sha256:f766050e491d0b3203b6b85638015f543816a2eb7d089fc04e86e00f6de0e31d"
],
"index": "pypi",
"version": "==1.48.2"
},
"protobuf": {
"hashes": [
"sha256:03038ac1cfbc41aa21f6afcbcd357281d7521b4157926f30ebecc8d4ea59dcb7",
"sha256:28545383d61f55b57cf4df63eebd9827754fd2dc25f80c5253f9184235db242c",
"sha256:2e3427429c9cffebf259491be0af70189607f365c2f41c7c3764af6f337105f2",
"sha256:398a9e0c3eaceb34ec1aee71894ca3299605fa8e761544934378bbc6c97de23b",
"sha256:44246bab5dd4b7fbd3c0c80b6f16686808fab0e4aca819ade6e8d294a29c7050",
"sha256:447d43819997825d4e71bf5769d869b968ce96848b6479397e29fc24c4a5dfe9",
"sha256:67a3598f0a2dcbc58d02dd1928544e7d88f764b47d4a286202913f0b2801c2e7",
"sha256:74480f79a023f90dc6e18febbf7b8bac7508420f2006fabd512013c0c238f454",
"sha256:819559cafa1a373b7096a482b504ae8a857c89593cf3a25af743ac9ecbd23480",
"sha256:899dc660cd599d7352d6f10d83c95df430a38b410c1b66b407a6b29265d66469",
"sha256:8c0c984a1b8fef4086329ff8dd19ac77576b384079247c770f29cc8ce3afa06c",
"sha256:9aae4406ea63d825636cc11ffb34ad3379335803216ee3a856787bcf5ccc751e",
"sha256:a7ca6d488aa8ff7f329d4c545b2dbad8ac31464f1d8b1c87ad1346717731e4db",
"sha256:b6cc7ba72a8850621bfec987cb72623e703b7fe2b9127a161ce61e61558ad905",
"sha256:bf01b5720be110540be4286e791db73f84a2b721072a3711efff6c324cdf074b",
"sha256:c02ce36ec760252242a33967d51c289fd0e1c0e6e5cc9397e2279177716add86",
"sha256:d9e4432ff660d67d775c66ac42a67cf2453c27cb4d738fc22cb53b5d84c135d4",
"sha256:daa564862dd0d39c00f8086f88700fdbe8bc717e993a21e90711acfed02f2402",
"sha256:de78575669dddf6099a8a0f46a27e82a1783c557ccc38ee620ed8cc96d3be7d7",
"sha256:e64857f395505ebf3d2569935506ae0dfc4a15cb80dc25261176c784662cdcc4",
"sha256:f4bd856d702e5b0d96a00ec6b307b0f51c1982c2bf9c0052cf9019e9a544ba99",
"sha256:f4c42102bc82a51108e449cbb32b19b180022941c727bac0cfd50170341f16ee"
],
"index": "pypi",
"version": "==3.20.3"
},
"setuptools": {
"hashes": [
"sha256:a7687c12b444eaac951ea87a9627c4f904ac757e7abdc5aac32833234af90378",
"sha256:e261cdf010c11a41cb5cb5f1bf3338a7433832029f559a6a7614bd42a967c300"
],
"markers": "python_version >= '3.7'",
"version": "==67.1.0"
}
},
"develop": {}
}

24
proto/generate_protobuf.py Executable file → Normal file
View File

@ -1,11 +1,12 @@
#!/usr/bin/env python3
import os
import sys
if __name__ == '__main__':
if 'PIPENV_ACTIVE' not in os.environ:
sys.exit(f'This script should be run in a Pipenv.\n\nRun it as:\npipenv run python {os.path.basename(__file__)}')
from pathlib import Path
import os
import shutil
import subprocess
import sys
def run():
SCRIPT_PATH = Path(__file__).absolute().parent
@ -30,5 +31,20 @@ def run():
subprocess.check_call(['python3', nanopb_generator_path, '-D', c_generated_output_path] + proto_files, cwd=proto_path)
# Use nanopb's packaged protoc to generate python bindings
protoc_path = nanopb_path / 'generator' / 'protoc'
python_generated_output_path = REPO_ROOT / 'software' / 'python' / 'proto_gen'
python_generated_output_path.mkdir(parents=True, exist_ok=True)
subprocess.check_call([protoc_path, '--version'])
subprocess.check_call([
protoc_path,
'--python_out',
python_generated_output_path,
] + proto_files, cwd=proto_path)
# Copy nanopb's compiled options proto
shutil.copy2(nanopb_path / 'generator' / 'proto' / 'nanopb_pb2.py', python_generated_output_path)
if __name__ == '__main__':
run()

View File

@ -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,32 +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;
/**
* 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;
/**
* 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 TO the Smartknob from the USB host
*/
message ToSmartknob {
uint32 nonce = 1;
oneof payload {
RequestState request_state = 2;
SmartKnobConfig smartknob_config = 3;
}
}
message SmartKnobConfig {
int32 num_positions = 1;
int32 position = 2;
float position_width_radians = 3;
float detent_strength_unit = 4;
float endstop_strength_unit = 5;
float snap_point = 6;
string text = 7 [(nanopb).max_length = 50];
/**
* 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;
/**
* 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;
}

View File

@ -1,4 +1,4 @@
# Typescript SmartKnob protobuf interface library
# Typescript SmartKnob protobuf interface library and examples
### Requirements (nvm is recommended)
@ -14,6 +14,7 @@ npm run build
### Example
A basic Node.js CLI example.
Connect the SmartKnob via USB, then run the example:
```

File diff suppressed because it is too large Load Diff

View File

@ -9,14 +9,21 @@
},
"scripts": {
"build": "npm run build --workspaces --if-present",
"example": "npm run -w example 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/example"
"packages/smartknobjs-core",
"packages/smartknobjs-node",
"packages/smartknobjs-webserial",
"packages/example-cli",
"packages/example-webserial-basic",
"packages/example-webserial-timeline"
],
"devDependencies": {
"eslint-config-prettier": "^8.5.0"

View File

@ -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",

View File

@ -1,16 +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,
)
})
@ -18,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(
@ -48,8 +53,11 @@ const main = async () => {
PB.SmartKnobConfig.create({
detentStrengthUnit: 1,
endstopStrengthUnit: 1,
numPositions: 5,
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,
snapPoint: 1.1,
text: 'From TS!',

View File

@ -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*

View File

@ -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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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/).

View File

@ -0,0 +1,55 @@
{
"name": "example-webserial-basic",
"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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -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>

View File

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

View File

@ -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>
</>
)
}

View File

@ -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>,
)

View File

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

View File

@ -0,0 +1,29 @@
export const exhaustiveCheck = (x: never): never => {
throw new Error("Didn't expect to get here", 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)
}

View File

@ -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"]
}

View File

@ -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
}
}

View File

@ -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*

View File

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

View File

@ -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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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/).

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -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>

View File

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

View File

@ -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>
)
}

View File

@ -0,0 +1,28 @@
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'
import {VideoInfo} from './types'
const theme = createTheme()
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
const info: VideoInfo = {
totalFrames: 30 * 60 * 5,
frameRate: 30,
boundaryFrames: [312, 400, 1234, 1290, 3000, 3300, 4000, 8000, 8100],
}
root.render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App info={info} />
</ThemeProvider>
</React.StrictMode>,
)

View File

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

View File

@ -0,0 +1,5 @@
export type VideoInfo = {
totalFrames: number
frameRate: number
boundaryFrames: number[]
}

View File

@ -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)
}

View File

@ -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"]
}

View File

@ -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
}
}

View File

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

View File

@ -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"
}
}

View File

@ -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)
}

View File

@ -1,97 +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 retryTimeout: ReturnType<typeof setTimeout> | null = null
protected portAvailable = false
private currentConfig: PB.SmartKnobConfig
constructor(serialPath: string | null, onMessage: MessageCallback) {
this.onMessage = onMessage
this.buffer = Buffer.alloc(0)
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({
detentStrengthUnit: 1,
endstopStrengthUnit: 1,
numPositions: 5,
position: 0,
positionWidthRadians: (10 * Math.PI) / 180,
snapPoint: 1.1,
text: 'From TS!',
})
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)}`)
@ -116,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) {
@ -125,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
@ -159,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) {
@ -174,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 encodedDelimitedPacket = Buffer.concat([encode(packet), Buffer.from([0])])
const packet = new Uint8Array(payload.length + 4)
packet.set(payload, 0)
packet.set(crcArray, payload.length)
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)
}
}

View File

@ -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"]
}

View File

@ -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
}
}

View File

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

View File

@ -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",

View File

@ -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
}
}

View File

@ -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"]
}

View File

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

View File

@ -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
}
}

View File

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

View File

@ -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"
}
}

View File

@ -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
}
}

View File

@ -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"]
}

View File

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

View File

@ -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"]
}

14
software/python/Pipfile Normal file
View File

@ -0,0 +1,14 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
pyserial = "*"
cobs = "*"
protobuf = "==3.20.3"
[dev-packages]
[requires]
python_version = "3.10"

73
software/python/Pipfile.lock generated Normal file
View File

@ -0,0 +1,73 @@
{
"_meta": {
"hash": {
"sha256": "a74717fa76c8f068ed910686866d0d3ad0ef124601f1d8cf37cc0592e71d4fee"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.10"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"cobs": {
"hashes": [
"sha256:3f2e8ef22070c3a95ed998303b81c209dd49aaaebf45173499a1a48b8b17bb6f",
"sha256:4625f7648e8b0ea50702cf213dcfcc0a50dc78fc6678b174f1f5e0d6fc3687c1",
"sha256:7c4a9e246c11f48e12c1231ad5738ea927f663b7b4ffd1de0be3b1cae5bb3884",
"sha256:991fc195f0581e456d3dd21e29df493fe9ca1b772084c26921601e48dbc34bd2",
"sha256:b4af64be97f2a7215c3ffcd5cca26e6aa166e49cd90941efab95c76211b6710c",
"sha256:c747c4b385f04203e1d2b767d61a69580b58c97eea7a6ed998edd42ddd9fcdc5",
"sha256:cb573184d982edf8e7c3c08c753b765790790e7ffdca4c2ca5c258bbd9099f33",
"sha256:d0f7e7fcad3020676feef505a0212bcd0c86549d3e4c3680f6a7c77ef599e52b",
"sha256:d93b10b5370dc8868960aefb70adb1f73a4e6107b16918307a4aeeefd3edb8f6",
"sha256:f68ee5e70ae6be79b424cb270835e864b86320a9fcb8048575443b658f61ee0a"
],
"index": "pypi",
"version": "==1.2.0"
},
"protobuf": {
"hashes": [
"sha256:03038ac1cfbc41aa21f6afcbcd357281d7521b4157926f30ebecc8d4ea59dcb7",
"sha256:28545383d61f55b57cf4df63eebd9827754fd2dc25f80c5253f9184235db242c",
"sha256:2e3427429c9cffebf259491be0af70189607f365c2f41c7c3764af6f337105f2",
"sha256:398a9e0c3eaceb34ec1aee71894ca3299605fa8e761544934378bbc6c97de23b",
"sha256:44246bab5dd4b7fbd3c0c80b6f16686808fab0e4aca819ade6e8d294a29c7050",
"sha256:447d43819997825d4e71bf5769d869b968ce96848b6479397e29fc24c4a5dfe9",
"sha256:67a3598f0a2dcbc58d02dd1928544e7d88f764b47d4a286202913f0b2801c2e7",
"sha256:74480f79a023f90dc6e18febbf7b8bac7508420f2006fabd512013c0c238f454",
"sha256:819559cafa1a373b7096a482b504ae8a857c89593cf3a25af743ac9ecbd23480",
"sha256:899dc660cd599d7352d6f10d83c95df430a38b410c1b66b407a6b29265d66469",
"sha256:8c0c984a1b8fef4086329ff8dd19ac77576b384079247c770f29cc8ce3afa06c",
"sha256:9aae4406ea63d825636cc11ffb34ad3379335803216ee3a856787bcf5ccc751e",
"sha256:a7ca6d488aa8ff7f329d4c545b2dbad8ac31464f1d8b1c87ad1346717731e4db",
"sha256:b6cc7ba72a8850621bfec987cb72623e703b7fe2b9127a161ce61e61558ad905",
"sha256:bf01b5720be110540be4286e791db73f84a2b721072a3711efff6c324cdf074b",
"sha256:c02ce36ec760252242a33967d51c289fd0e1c0e6e5cc9397e2279177716add86",
"sha256:d9e4432ff660d67d775c66ac42a67cf2453c27cb4d738fc22cb53b5d84c135d4",
"sha256:daa564862dd0d39c00f8086f88700fdbe8bc717e993a21e90711acfed02f2402",
"sha256:de78575669dddf6099a8a0f46a27e82a1783c557ccc38ee620ed8cc96d3be7d7",
"sha256:e64857f395505ebf3d2569935506ae0dfc4a15cb80dc25261176c784662cdcc4",
"sha256:f4bd856d702e5b0d96a00ec6b307b0f51c1982c2bf9c0052cf9019e9a544ba99",
"sha256:f4c42102bc82a51108e449cbb32b19b180022941c727bac0cfd50170341f16ee"
],
"index": "pypi",
"version": "==3.20.3"
},
"pyserial": {
"hashes": [
"sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb",
"sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"
],
"index": "pypi",
"version": "==3.5"
}
},
"develop": {}
}

View File

@ -0,0 +1,5 @@
The files in this directory are auto-generated by the protobuf compiler.
They're checked into the repo to avoid the need to always re-compile them.
They can be regenerated by running `<repo_root>/proto/generate_protobuf.py`

View File

@ -0,0 +1,508 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: nanopb.proto
from google.protobuf.internal import enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2
DESCRIPTOR = _descriptor.FileDescriptor(
name='nanopb.proto',
package='',
syntax='proto2',
serialized_options=b'\n\030fi.kapsi.koti.jpa.nanopb',
create_key=_descriptor._internal_create_key,
serialized_pb=b'\n\x0cnanopb.proto\x1a google/protobuf/descriptor.proto\"\xa4\x07\n\rNanoPBOptions\x12\x10\n\x08max_size\x18\x01 \x01(\x05\x12\x12\n\nmax_length\x18\x0e \x01(\x05\x12\x11\n\tmax_count\x18\x02 \x01(\x05\x12&\n\x08int_size\x18\x07 \x01(\x0e\x32\x08.IntSize:\nIS_DEFAULT\x12$\n\x04type\x18\x03 \x01(\x0e\x32\n.FieldType:\nFT_DEFAULT\x12\x18\n\nlong_names\x18\x04 \x01(\x08:\x04true\x12\x1c\n\rpacked_struct\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1a\n\x0bpacked_enum\x18\n \x01(\x08:\x05\x66\x61lse\x12\x1b\n\x0cskip_message\x18\x06 \x01(\x08:\x05\x66\x61lse\x12\x18\n\tno_unions\x18\x08 \x01(\x08:\x05\x66\x61lse\x12\r\n\x05msgid\x18\t \x01(\r\x12\x1e\n\x0f\x61nonymous_oneof\x18\x0b \x01(\x08:\x05\x66\x61lse\x12\x15\n\x06proto3\x18\x0c \x01(\x08:\x05\x66\x61lse\x12#\n\x14proto3_singular_msgs\x18\x15 \x01(\x08:\x05\x66\x61lse\x12\x1d\n\x0e\x65num_to_string\x18\r \x01(\x08:\x05\x66\x61lse\x12\x1b\n\x0c\x66ixed_length\x18\x0f \x01(\x08:\x05\x66\x61lse\x12\x1a\n\x0b\x66ixed_count\x18\x10 \x01(\x08:\x05\x66\x61lse\x12\x1e\n\x0fsubmsg_callback\x18\x16 \x01(\x08:\x05\x66\x61lse\x12/\n\x0cmangle_names\x18\x11 \x01(\x0e\x32\x11.TypenameMangling:\x06M_NONE\x12(\n\x11\x63\x61llback_datatype\x18\x12 \x01(\t:\rpb_callback_t\x12\x34\n\x11\x63\x61llback_function\x18\x13 \x01(\t:\x19pb_default_field_callback\x12\x30\n\x0e\x64\x65scriptorsize\x18\x14 \x01(\x0e\x32\x0f.DescriptorSize:\x07\x44S_AUTO\x12\x1a\n\x0b\x64\x65\x66\x61ult_has\x18\x17 \x01(\x08:\x05\x66\x61lse\x12\x0f\n\x07include\x18\x18 \x03(\t\x12\x0f\n\x07\x65xclude\x18\x1a \x03(\t\x12\x0f\n\x07package\x18\x19 \x01(\t\x12\x41\n\rtype_override\x18\x1b \x01(\x0e\x32*.google.protobuf.FieldDescriptorProto.Type\x12\x19\n\x0bsort_by_tag\x18\x1c \x01(\x08:\x04true\x12.\n\rfallback_type\x18\x1d \x01(\x0e\x32\n.FieldType:\x0b\x46T_CALLBACK*i\n\tFieldType\x12\x0e\n\nFT_DEFAULT\x10\x00\x12\x0f\n\x0b\x46T_CALLBACK\x10\x01\x12\x0e\n\nFT_POINTER\x10\x04\x12\r\n\tFT_STATIC\x10\x02\x12\r\n\tFT_IGNORE\x10\x03\x12\r\n\tFT_INLINE\x10\x05*D\n\x07IntSize\x12\x0e\n\nIS_DEFAULT\x10\x00\x12\x08\n\x04IS_8\x10\x08\x12\t\n\x05IS_16\x10\x10\x12\t\n\x05IS_32\x10 \x12\t\n\x05IS_64\x10@*Z\n\x10TypenameMangling\x12\n\n\x06M_NONE\x10\x00\x12\x13\n\x0fM_STRIP_PACKAGE\x10\x01\x12\r\n\tM_FLATTEN\x10\x02\x12\x16\n\x12M_PACKAGE_INITIALS\x10\x03*E\n\x0e\x44\x65scriptorSize\x12\x0b\n\x07\x44S_AUTO\x10\x00\x12\x08\n\x04\x44S_1\x10\x01\x12\x08\n\x04\x44S_2\x10\x02\x12\x08\n\x04\x44S_4\x10\x04\x12\x08\n\x04\x44S_8\x10\x08:E\n\x0enanopb_fileopt\x12\x1c.google.protobuf.FileOptions\x18\xf2\x07 \x01(\x0b\x32\x0e.NanoPBOptions:G\n\rnanopb_msgopt\x12\x1f.google.protobuf.MessageOptions\x18\xf2\x07 \x01(\x0b\x32\x0e.NanoPBOptions:E\n\x0enanopb_enumopt\x12\x1c.google.protobuf.EnumOptions\x18\xf2\x07 \x01(\x0b\x32\x0e.NanoPBOptions:>\n\x06nanopb\x12\x1d.google.protobuf.FieldOptions\x18\xf2\x07 \x01(\x0b\x32\x0e.NanoPBOptionsB\x1a\n\x18\x66i.kapsi.koti.jpa.nanopb'
,
dependencies=[google_dot_protobuf_dot_descriptor__pb2.DESCRIPTOR,])
_FIELDTYPE = _descriptor.EnumDescriptor(
name='FieldType',
full_name='FieldType',
filename=None,
file=DESCRIPTOR,
create_key=_descriptor._internal_create_key,
values=[
_descriptor.EnumValueDescriptor(
name='FT_DEFAULT', index=0, number=0,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='FT_CALLBACK', index=1, number=1,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='FT_POINTER', index=2, number=4,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='FT_STATIC', index=3, number=2,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='FT_IGNORE', index=4, number=3,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='FT_INLINE', index=5, number=5,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
],
containing_type=None,
serialized_options=None,
serialized_start=985,
serialized_end=1090,
)
_sym_db.RegisterEnumDescriptor(_FIELDTYPE)
FieldType = enum_type_wrapper.EnumTypeWrapper(_FIELDTYPE)
_INTSIZE = _descriptor.EnumDescriptor(
name='IntSize',
full_name='IntSize',
filename=None,
file=DESCRIPTOR,
create_key=_descriptor._internal_create_key,
values=[
_descriptor.EnumValueDescriptor(
name='IS_DEFAULT', index=0, number=0,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='IS_8', index=1, number=8,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='IS_16', index=2, number=16,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='IS_32', index=3, number=32,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='IS_64', index=4, number=64,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
],
containing_type=None,
serialized_options=None,
serialized_start=1092,
serialized_end=1160,
)
_sym_db.RegisterEnumDescriptor(_INTSIZE)
IntSize = enum_type_wrapper.EnumTypeWrapper(_INTSIZE)
_TYPENAMEMANGLING = _descriptor.EnumDescriptor(
name='TypenameMangling',
full_name='TypenameMangling',
filename=None,
file=DESCRIPTOR,
create_key=_descriptor._internal_create_key,
values=[
_descriptor.EnumValueDescriptor(
name='M_NONE', index=0, number=0,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='M_STRIP_PACKAGE', index=1, number=1,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='M_FLATTEN', index=2, number=2,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='M_PACKAGE_INITIALS', index=3, number=3,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
],
containing_type=None,
serialized_options=None,
serialized_start=1162,
serialized_end=1252,
)
_sym_db.RegisterEnumDescriptor(_TYPENAMEMANGLING)
TypenameMangling = enum_type_wrapper.EnumTypeWrapper(_TYPENAMEMANGLING)
_DESCRIPTORSIZE = _descriptor.EnumDescriptor(
name='DescriptorSize',
full_name='DescriptorSize',
filename=None,
file=DESCRIPTOR,
create_key=_descriptor._internal_create_key,
values=[
_descriptor.EnumValueDescriptor(
name='DS_AUTO', index=0, number=0,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='DS_1', index=1, number=1,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='DS_2', index=2, number=2,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='DS_4', index=3, number=4,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
_descriptor.EnumValueDescriptor(
name='DS_8', index=4, number=8,
serialized_options=None,
type=None,
create_key=_descriptor._internal_create_key),
],
containing_type=None,
serialized_options=None,
serialized_start=1254,
serialized_end=1323,
)
_sym_db.RegisterEnumDescriptor(_DESCRIPTORSIZE)
DescriptorSize = enum_type_wrapper.EnumTypeWrapper(_DESCRIPTORSIZE)
FT_DEFAULT = 0
FT_CALLBACK = 1
FT_POINTER = 4
FT_STATIC = 2
FT_IGNORE = 3
FT_INLINE = 5
IS_DEFAULT = 0
IS_8 = 8
IS_16 = 16
IS_32 = 32
IS_64 = 64
M_NONE = 0
M_STRIP_PACKAGE = 1
M_FLATTEN = 2
M_PACKAGE_INITIALS = 3
DS_AUTO = 0
DS_1 = 1
DS_2 = 2
DS_4 = 4
DS_8 = 8
NANOPB_FILEOPT_FIELD_NUMBER = 1010
nanopb_fileopt = _descriptor.FieldDescriptor(
name='nanopb_fileopt', full_name='nanopb_fileopt', index=0,
number=1010, type=11, cpp_type=10, label=1,
has_default_value=False, default_value=None,
message_type=None, enum_type=None, containing_type=None,
is_extension=True, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key)
NANOPB_MSGOPT_FIELD_NUMBER = 1010
nanopb_msgopt = _descriptor.FieldDescriptor(
name='nanopb_msgopt', full_name='nanopb_msgopt', index=1,
number=1010, type=11, cpp_type=10, label=1,
has_default_value=False, default_value=None,
message_type=None, enum_type=None, containing_type=None,
is_extension=True, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key)
NANOPB_ENUMOPT_FIELD_NUMBER = 1010
nanopb_enumopt = _descriptor.FieldDescriptor(
name='nanopb_enumopt', full_name='nanopb_enumopt', index=2,
number=1010, type=11, cpp_type=10, label=1,
has_default_value=False, default_value=None,
message_type=None, enum_type=None, containing_type=None,
is_extension=True, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key)
NANOPB_FIELD_NUMBER = 1010
nanopb = _descriptor.FieldDescriptor(
name='nanopb', full_name='nanopb', index=3,
number=1010, type=11, cpp_type=10, label=1,
has_default_value=False, default_value=None,
message_type=None, enum_type=None, containing_type=None,
is_extension=True, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key)
_NANOPBOPTIONS = _descriptor.Descriptor(
name='NanoPBOptions',
full_name='NanoPBOptions',
filename=None,
file=DESCRIPTOR,
containing_type=None,
create_key=_descriptor._internal_create_key,
fields=[
_descriptor.FieldDescriptor(
name='max_size', full_name='NanoPBOptions.max_size', index=0,
number=1, type=5, cpp_type=1, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='max_length', full_name='NanoPBOptions.max_length', index=1,
number=14, type=5, cpp_type=1, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='max_count', full_name='NanoPBOptions.max_count', index=2,
number=2, type=5, cpp_type=1, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='int_size', full_name='NanoPBOptions.int_size', index=3,
number=7, type=14, cpp_type=8, label=1,
has_default_value=True, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='type', full_name='NanoPBOptions.type', index=4,
number=3, type=14, cpp_type=8, label=1,
has_default_value=True, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='long_names', full_name='NanoPBOptions.long_names', index=5,
number=4, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=True,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='packed_struct', full_name='NanoPBOptions.packed_struct', index=6,
number=5, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='packed_enum', full_name='NanoPBOptions.packed_enum', index=7,
number=10, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='skip_message', full_name='NanoPBOptions.skip_message', index=8,
number=6, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='no_unions', full_name='NanoPBOptions.no_unions', index=9,
number=8, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='msgid', full_name='NanoPBOptions.msgid', index=10,
number=9, type=13, cpp_type=3, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='anonymous_oneof', full_name='NanoPBOptions.anonymous_oneof', index=11,
number=11, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='proto3', full_name='NanoPBOptions.proto3', index=12,
number=12, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='proto3_singular_msgs', full_name='NanoPBOptions.proto3_singular_msgs', index=13,
number=21, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='enum_to_string', full_name='NanoPBOptions.enum_to_string', index=14,
number=13, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='fixed_length', full_name='NanoPBOptions.fixed_length', index=15,
number=15, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='fixed_count', full_name='NanoPBOptions.fixed_count', index=16,
number=16, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='submsg_callback', full_name='NanoPBOptions.submsg_callback', index=17,
number=22, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='mangle_names', full_name='NanoPBOptions.mangle_names', index=18,
number=17, type=14, cpp_type=8, label=1,
has_default_value=True, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='callback_datatype', full_name='NanoPBOptions.callback_datatype', index=19,
number=18, type=9, cpp_type=9, label=1,
has_default_value=True, default_value=b"pb_callback_t".decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='callback_function', full_name='NanoPBOptions.callback_function', index=20,
number=19, type=9, cpp_type=9, label=1,
has_default_value=True, default_value=b"pb_default_field_callback".decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='descriptorsize', full_name='NanoPBOptions.descriptorsize', index=21,
number=20, type=14, cpp_type=8, label=1,
has_default_value=True, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='default_has', full_name='NanoPBOptions.default_has', index=22,
number=23, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='include', full_name='NanoPBOptions.include', index=23,
number=24, type=9, cpp_type=9, label=3,
has_default_value=False, default_value=[],
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='exclude', full_name='NanoPBOptions.exclude', index=24,
number=26, type=9, cpp_type=9, label=3,
has_default_value=False, default_value=[],
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='package', full_name='NanoPBOptions.package', index=25,
number=25, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=b"".decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='type_override', full_name='NanoPBOptions.type_override', index=26,
number=27, type=14, cpp_type=8, label=1,
has_default_value=False, default_value=1,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='sort_by_tag', full_name='NanoPBOptions.sort_by_tag', index=27,
number=28, type=8, cpp_type=7, label=1,
has_default_value=True, default_value=True,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='fallback_type', full_name='NanoPBOptions.fallback_type', index=28,
number=29, type=14, cpp_type=8, label=1,
has_default_value=True, default_value=1,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
],
extensions=[
],
nested_types=[],
enum_types=[
],
serialized_options=None,
is_extendable=False,
syntax='proto2',
extension_ranges=[],
oneofs=[
],
serialized_start=51,
serialized_end=983,
)
_NANOPBOPTIONS.fields_by_name['int_size'].enum_type = _INTSIZE
_NANOPBOPTIONS.fields_by_name['type'].enum_type = _FIELDTYPE
_NANOPBOPTIONS.fields_by_name['mangle_names'].enum_type = _TYPENAMEMANGLING
_NANOPBOPTIONS.fields_by_name['descriptorsize'].enum_type = _DESCRIPTORSIZE
_NANOPBOPTIONS.fields_by_name['type_override'].enum_type = google_dot_protobuf_dot_descriptor__pb2._FIELDDESCRIPTORPROTO_TYPE
_NANOPBOPTIONS.fields_by_name['fallback_type'].enum_type = _FIELDTYPE
DESCRIPTOR.message_types_by_name['NanoPBOptions'] = _NANOPBOPTIONS
DESCRIPTOR.enum_types_by_name['FieldType'] = _FIELDTYPE
DESCRIPTOR.enum_types_by_name['IntSize'] = _INTSIZE
DESCRIPTOR.enum_types_by_name['TypenameMangling'] = _TYPENAMEMANGLING
DESCRIPTOR.enum_types_by_name['DescriptorSize'] = _DESCRIPTORSIZE
DESCRIPTOR.extensions_by_name['nanopb_fileopt'] = nanopb_fileopt
DESCRIPTOR.extensions_by_name['nanopb_msgopt'] = nanopb_msgopt
DESCRIPTOR.extensions_by_name['nanopb_enumopt'] = nanopb_enumopt
DESCRIPTOR.extensions_by_name['nanopb'] = nanopb
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
NanoPBOptions = _reflection.GeneratedProtocolMessageType('NanoPBOptions', (_message.Message,), {
'DESCRIPTOR' : _NANOPBOPTIONS,
'__module__' : 'nanopb_pb2'
# @@protoc_insertion_point(class_scope:NanoPBOptions)
})
_sym_db.RegisterMessage(NanoPBOptions)
nanopb_fileopt.message_type = _NANOPBOPTIONS
google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension(nanopb_fileopt)
nanopb_msgopt.message_type = _NANOPBOPTIONS
google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension(nanopb_msgopt)
nanopb_enumopt.message_type = _NANOPBOPTIONS
google_dot_protobuf_dot_descriptor__pb2.EnumOptions.RegisterExtension(nanopb_enumopt)
nanopb.message_type = _NANOPBOPTIONS
google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension(nanopb)
DESCRIPTOR._options = None
# @@protoc_insertion_point(module_scope)

View File

@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: smartknob.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
import nanopb_pb2 as nanopb__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0fsmartknob.proto\x12\x02PB\x1a\x0cnanopb.proto\"\x9a\x01\n\rFromSmartKnob\x12\x1f\n\x10protocol_version\x18\x01 \x01(\rB\x05\x92?\x02\x38\x08\x12\x16\n\x03\x61\x63k\x18\x02 \x01(\x0b\x32\x07.PB.AckH\x00\x12\x16\n\x03log\x18\x03 \x01(\x0b\x32\x07.PB.LogH\x00\x12-\n\x0fsmartknob_state\x18\x04 \x01(\x0b\x32\x12.PB.SmartKnobStateH\x00\x42\t\n\x07payload\"\xa4\x01\n\x0bToSmartknob\x12\x1f\n\x10protocol_version\x18\x01 \x01(\rB\x05\x92?\x02\x38\x08\x12\r\n\x05nonce\x18\x02 \x01(\r\x12)\n\rrequest_state\x18\x03 \x01(\x0b\x32\x10.PB.RequestStateH\x00\x12/\n\x10smartknob_config\x18\x04 \x01(\x0b\x32\x13.PB.SmartKnobConfigH\x00\x42\t\n\x07payload\"\x14\n\x03\x41\x63k\x12\r\n\x05nonce\x18\x01 \x01(\r\"\x1a\n\x03Log\x12\x13\n\x03msg\x18\x01 \x01(\tB\x06\x92?\x03p\xff\x01\"\x86\x01\n\x0eSmartKnobState\x12\x18\n\x10\x63urrent_position\x18\x01 \x01(\x05\x12\x19\n\x11sub_position_unit\x18\x02 \x01(\x02\x12#\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x13.PB.SmartKnobConfig\x12\x1a\n\x0bpress_nonce\x18\x04 \x01(\rB\x05\x92?\x02\x38\x08\"\xe1\x02\n\x0fSmartKnobConfig\x12\x10\n\x08position\x18\x01 \x01(\x05\x12\x19\n\x11sub_position_unit\x18\x02 \x01(\x02\x12\x1d\n\x0eposition_nonce\x18\x03 \x01(\rB\x05\x92?\x02\x38\x08\x12\x14\n\x0cmin_position\x18\x04 \x01(\x05\x12\x14\n\x0cmax_position\x18\x05 \x01(\x05\x12\x1e\n\x16position_width_radians\x18\x06 \x01(\x02\x12\x1c\n\x14\x64\x65tent_strength_unit\x18\x07 \x01(\x02\x12\x1d\n\x15\x65ndstop_strength_unit\x18\x08 \x01(\x02\x12\x12\n\nsnap_point\x18\t \x01(\x02\x12\x13\n\x04text\x18\n \x01(\tB\x05\x92?\x02p2\x12\x1f\n\x10\x64\x65tent_positions\x18\x0b \x03(\x05\x42\x05\x92?\x02\x10\x05\x12\x17\n\x0fsnap_point_bias\x18\x0c \x01(\x02\x12\x16\n\x07led_hue\x18\r \x01(\x05\x42\x05\x92?\x02\x38\x10\"\x0e\n\x0cRequestState\"v\n\x17PersistentConfiguration\x12\x0f\n\x07version\x18\x01 \x01(\r\x12#\n\x05motor\x18\x02 \x01(\x0b\x32\x14.PB.MotorCalibration\x12%\n\x06strain\x18\x03 \x01(\x0b\x32\x15.PB.StrainCalibration\"p\n\x10MotorCalibration\x12\x12\n\ncalibrated\x18\x01 \x01(\x08\x12\x1e\n\x16zero_electrical_offset\x18\x02 \x01(\x02\x12\x14\n\x0c\x64irection_cw\x18\x03 \x01(\x08\x12\x12\n\npole_pairs\x18\x04 \x01(\r\"<\n\x11StrainCalibration\x12\x12\n\nidle_value\x18\x01 \x01(\x05\x12\x13\n\x0bpress_delta\x18\x02 \x01(\x05\x62\x06proto3')
_FROMSMARTKNOB = DESCRIPTOR.message_types_by_name['FromSmartKnob']
_TOSMARTKNOB = DESCRIPTOR.message_types_by_name['ToSmartknob']
_ACK = DESCRIPTOR.message_types_by_name['Ack']
_LOG = DESCRIPTOR.message_types_by_name['Log']
_SMARTKNOBSTATE = DESCRIPTOR.message_types_by_name['SmartKnobState']
_SMARTKNOBCONFIG = DESCRIPTOR.message_types_by_name['SmartKnobConfig']
_REQUESTSTATE = DESCRIPTOR.message_types_by_name['RequestState']
_PERSISTENTCONFIGURATION = DESCRIPTOR.message_types_by_name['PersistentConfiguration']
_MOTORCALIBRATION = DESCRIPTOR.message_types_by_name['MotorCalibration']
_STRAINCALIBRATION = DESCRIPTOR.message_types_by_name['StrainCalibration']
FromSmartKnob = _reflection.GeneratedProtocolMessageType('FromSmartKnob', (_message.Message,), {
'DESCRIPTOR' : _FROMSMARTKNOB,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.FromSmartKnob)
})
_sym_db.RegisterMessage(FromSmartKnob)
ToSmartknob = _reflection.GeneratedProtocolMessageType('ToSmartknob', (_message.Message,), {
'DESCRIPTOR' : _TOSMARTKNOB,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.ToSmartknob)
})
_sym_db.RegisterMessage(ToSmartknob)
Ack = _reflection.GeneratedProtocolMessageType('Ack', (_message.Message,), {
'DESCRIPTOR' : _ACK,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.Ack)
})
_sym_db.RegisterMessage(Ack)
Log = _reflection.GeneratedProtocolMessageType('Log', (_message.Message,), {
'DESCRIPTOR' : _LOG,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.Log)
})
_sym_db.RegisterMessage(Log)
SmartKnobState = _reflection.GeneratedProtocolMessageType('SmartKnobState', (_message.Message,), {
'DESCRIPTOR' : _SMARTKNOBSTATE,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.SmartKnobState)
})
_sym_db.RegisterMessage(SmartKnobState)
SmartKnobConfig = _reflection.GeneratedProtocolMessageType('SmartKnobConfig', (_message.Message,), {
'DESCRIPTOR' : _SMARTKNOBCONFIG,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.SmartKnobConfig)
})
_sym_db.RegisterMessage(SmartKnobConfig)
RequestState = _reflection.GeneratedProtocolMessageType('RequestState', (_message.Message,), {
'DESCRIPTOR' : _REQUESTSTATE,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.RequestState)
})
_sym_db.RegisterMessage(RequestState)
PersistentConfiguration = _reflection.GeneratedProtocolMessageType('PersistentConfiguration', (_message.Message,), {
'DESCRIPTOR' : _PERSISTENTCONFIGURATION,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.PersistentConfiguration)
})
_sym_db.RegisterMessage(PersistentConfiguration)
MotorCalibration = _reflection.GeneratedProtocolMessageType('MotorCalibration', (_message.Message,), {
'DESCRIPTOR' : _MOTORCALIBRATION,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.MotorCalibration)
})
_sym_db.RegisterMessage(MotorCalibration)
StrainCalibration = _reflection.GeneratedProtocolMessageType('StrainCalibration', (_message.Message,), {
'DESCRIPTOR' : _STRAINCALIBRATION,
'__module__' : 'smartknob_pb2'
# @@protoc_insertion_point(class_scope:PB.StrainCalibration)
})
_sym_db.RegisterMessage(StrainCalibration)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_FROMSMARTKNOB.fields_by_name['protocol_version']._options = None
_FROMSMARTKNOB.fields_by_name['protocol_version']._serialized_options = b'\222?\0028\010'
_TOSMARTKNOB.fields_by_name['protocol_version']._options = None
_TOSMARTKNOB.fields_by_name['protocol_version']._serialized_options = b'\222?\0028\010'
_LOG.fields_by_name['msg']._options = None
_LOG.fields_by_name['msg']._serialized_options = b'\222?\003p\377\001'
_SMARTKNOBSTATE.fields_by_name['press_nonce']._options = None
_SMARTKNOBSTATE.fields_by_name['press_nonce']._serialized_options = b'\222?\0028\010'
_SMARTKNOBCONFIG.fields_by_name['position_nonce']._options = None
_SMARTKNOBCONFIG.fields_by_name['position_nonce']._serialized_options = b'\222?\0028\010'
_SMARTKNOBCONFIG.fields_by_name['text']._options = None
_SMARTKNOBCONFIG.fields_by_name['text']._serialized_options = b'\222?\002p2'
_SMARTKNOBCONFIG.fields_by_name['detent_positions']._options = None
_SMARTKNOBCONFIG.fields_by_name['detent_positions']._serialized_options = b'\222?\002\020\005'
_SMARTKNOBCONFIG.fields_by_name['led_hue']._options = None
_SMARTKNOBCONFIG.fields_by_name['led_hue']._serialized_options = b'\222?\0028\020'
_FROMSMARTKNOB._serialized_start=38
_FROMSMARTKNOB._serialized_end=192
_TOSMARTKNOB._serialized_start=195
_TOSMARTKNOB._serialized_end=359
_ACK._serialized_start=361
_ACK._serialized_end=381
_LOG._serialized_start=383
_LOG._serialized_end=409
_SMARTKNOBSTATE._serialized_start=412
_SMARTKNOBSTATE._serialized_end=546
_SMARTKNOBCONFIG._serialized_start=549
_SMARTKNOBCONFIG._serialized_end=902
_REQUESTSTATE._serialized_start=904
_REQUESTSTATE._serialized_end=918
_PERSISTENTCONFIGURATION._serialized_start=920
_PERSISTENTCONFIGURATION._serialized_end=1038
_MOTORCALIBRATION._serialized_start=1040
_MOTORCALIBRATION._serialized_end=1152
_STRAINCALIBRATION._serialized_start=1154
_STRAINCALIBRATION._serialized_end=1214
# @@protoc_insertion_point(module_scope)

View File

@ -0,0 +1,56 @@
import os
import sys
if __name__ == '__main__':
if 'PIPENV_ACTIVE' not in os.environ:
sys.exit(f'This script should be run in a Pipenv.\n\nRun it as:\npipenv run python {os.path.basename(__file__)}')
# Place imports below this line
import logging
import math
from smartknob_io import (
ask_for_serial_port,
smartknob_context
)
from proto_gen import smartknob_pb2
def _run_example():
logging.basicConfig(level=logging.INFO)
p = ask_for_serial_port()
with smartknob_context(p) as s:
# Initialize with an empty state object
last_state = smartknob_pb2.SmartKnobState()
# Callback function to handle state updates
def log_state(new_state):
nonlocal last_state
# 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)
# Run forever, set config when enter is pressed
while True:
input()
config = smartknob_pb2.SmartKnobConfig()
config.position = 0
config.min_position = 0
config.max_position = 5
config.position_width_radians = math.radians(10)
config.detent_strength_unit = 1
config.endstop_strength_unit = 1
config.snap_point = 1.1
config.text = "From Python!"
s.set_config(config)
if __name__ == '__main__':
_run_example()

View File

@ -0,0 +1,262 @@
if __name__ == '__main__':
import sys
sys.exit('This is a library file to be imported into your own python scripts. It doesn\'t do anything if run directly')
from cobs import cobs
from collections import (
defaultdict,
)
from contextlib import contextmanager
from enum import Enum
import logging
import os
from queue import (
Empty,
Full,
Queue,
)
from random import randint
import serial
import serial.tools.list_ports
import sys
from threading import (
Thread,
Lock,
)
import time
import zlib
software_root = os.path.dirname(os.path.abspath(__file__))
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):
RETRY_TIMEOUT = 0.25
def __init__(self, serial_instance):
self._serial = serial_instance
self._logger = logging.getLogger('smartknob')
self._out_q = Queue()
self._ack_q = Queue()
self._next_nonce = randint(0, 255)
self._run = True
self._lock = Lock()
self._message_handlers = defaultdict(list)
def _read_loop(self):
self._logger.debug('Read loop started')
buffer = b''
while True:
buffer += self._serial.read_until(b'\0')
if not self._run:
return
if not len(buffer):
continue
if not buffer.endswith(b'\0'):
continue
self._process_frame(buffer[:-1])
buffer = b''
def _process_frame(self, frame):
try:
decoded = cobs.decode(frame)
except cobs.DecodeError:
self._logger.debug(f'Failed decode ({len(frame)} bytes)')
self._logger.debug(frame)
return
if len(decoded) < 4:
return
payload = decoded[:-4]
expected_crc = zlib.crc32(payload) & 0xffffffff
provided_crc = (decoded[-1] << 24) \
| (decoded[-2] << 16) \
| (decoded[-3] << 8) \
| decoded[-4]
if expected_crc != provided_crc:
self._logger.debug(f'Bad CRC. expected={hex(expected_crc)}, actual={hex(provided_crc)}')
return
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')
# If this is an ack, notify the write thread
if payload_type == 'ack':
nonce = message.ack.nonce
self._ack_q.put(nonce)
with self._lock:
for handler in self._message_handlers[payload_type] + self._message_handlers[None]:
try:
handler(getattr(message, payload_type))
except:
self._logger.warning(f'Unhandled exception in message handler ({payload_type})', exc_info=True)
def _write_loop(self):
self._logger.debug('Write loop started')
while True:
data = self._out_q.get()
# Check for shutdown
if not self._run:
self._logger.debug('Write loop exiting @ _out_q')
return
(nonce, encoded_message) = data
next_retry = 0
while True:
if time.time() >= next_retry:
if next_retry > 0:
self._logger.debug('Retry write...')
self._serial.write(encoded_message)
self._serial.write(b'\0')
next_retry = time.time() + Smartknob.RETRY_TIMEOUT
try:
latest_ack_nonce = self._ack_q.get(timeout=next_retry - time.time())
except Empty:
latest_ack_nonce = None
# Check for shutdown
if not self._run:
self._logger.debug('Write loop exiting @ _ack_q')
return
if latest_ack_nonce == nonce:
break
else:
self._logger.debug(f'Got unexpected nonce: {latest_ack_nonce}')
def _enqueue_message(self, message):
nonce = self._next_nonce
self._next_nonce += 1
message.protocol_version = PROTOBUF_PROTOCOL_VERSION
message.nonce = nonce
payload = bytearray(message.SerializeToString())
crc = zlib.crc32(payload) & 0xffffffff
payload.append(crc & 0xff)
payload.append((crc >> 8) & 0xff)
payload.append((crc >> 16) & 0xff)
payload.append((crc >> 24) & 0xff)
encoded_message = cobs.encode(payload)
self._out_q.put((nonce, encoded_message))
approx_q_length = self._out_q.qsize()
self._logger.debug(f'Out q length: {approx_q_length}')
if approx_q_length > 10:
self._logger.warning(f'Output queue length is high! ({approx_q_length}) Is the smartknob still connected and functional?')
def set_config(self, config):
message = smartknob_pb2.ToSmartknob()
message.smartknob_config.CopyFrom(config)
self._enqueue_message(message)
def start(self):
self.read_thread = Thread(target=self._read_loop)
self.write_thread = Thread(target=self._write_loop)
self.read_thread.start()
self.write_thread.start()
def shutdown(self):
self._logger.info('Shutting down...')
self._run = False
self.read_thread.join()
self._logger.debug('Read thread terminated')
self._out_q.put(None)
self._ack_q.put(None)
self.write_thread.join()
self._logger.debug('Write thread terminated')
def add_handler(self, message_type, handler):
with self._lock:
self._message_handlers[message_type].append(handler)
return lambda: self._remove_handler(message_type, handler)
def _remove_handler(self, message_type, handler):
with self._lock:
self._message_handlers[message_type].remove(handler)
def request_state(self):
message = smartknob_pb2.ToSmartknob()
message.request_state.SetInParent()
self._enqueue_message(message)
def hard_reset(self):
self._serial.setRTS(True)
self._serial.setDTR(False)
time.sleep(0.2)
self._serial.setDTR(True)
time.sleep(0.2)
@contextmanager
def smartknob_context(serial_port, default_logging=True, wait_for_comms=True):
with serial.Serial(serial_port, SMARTKNOB_BAUD, timeout=1.0) as ser:
s = Smartknob(ser)
s.start()
if default_logging:
s.add_handler('log', lambda msg: s._logger.info(f'From smartknob: {msg.msg}'))
if wait_for_comms:
s._logger.info('Connecting to smartknob...')
q = Queue(1)
def startup_handler(message):
try:
q.put_nowait(None)
except Full:
pass
unregister = s.add_handler('smartknob_state', startup_handler)
s.request_state()
q.get()
unregister()
s._logger.info('Connected!')
try:
yield s
finally:
s.shutdown()
def ask_for_serial_port():
"""
Helper function to ask which port to use via stdin
"""
print('Available ports:')
ports = sorted(
filter(
lambda p: p.description != 'n/a',
serial.tools.list_ports.comports(),
),
key=lambda p: p.device,
)
for i, port in enumerate(ports):
print('[{: 2}] {} - {}'.format(i, port.device, port.description))
print()
value = input('Use which port? ')
port_index = int(value)
assert 0 <= port_index < len(ports)
return ports[port_index].device

2
thirdparty/nanopb vendored

@ -1 +1 @@
Subproject commit 80f9d5bcbc0da72c95d3411b642c109a14298d75
Subproject commit b97aa657a706d3ba4a9a6ccca7043c9d6fe41cba