Преглед на файлове

Mostly working frame and speed modes, breaking proto change

Scott Bezek преди 3 години
родител
ревизия
8a54c0819f

+ 12 - 10
firmware/src/display_task.cpp

@@ -22,7 +22,7 @@ DisplayTask::~DisplayTask() {
 void DisplayTask::run() {
     tft_.begin();
     tft_.invertDisplay(1);
-    tft_.setRotation(0);
+    tft_.setRotation(2);
     tft_.fillScreen(TFT_DARKGREEN);
 
     ledcSetup(LEDC_CHANNEL_LCD_BACKLIGHT, 5000, 16);
@@ -54,8 +54,10 @@ 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);
+
+        int32_t num_positions = state.config.max_position - state.config.min_position + 1;
+        if (num_positions > 1) {
+          int32_t height = (state.current_position - state.config.min_position) * TFT_HEIGHT / (state.config.max_position - state.config.min_position);
           spr_.fillRect(0, TFT_HEIGHT - height, TFT_WIDTH, height, FILL_COLOR);
         }
 
@@ -80,8 +82,8 @@ void DisplayTask::run() {
 
         float left_bound = PI / 2;
 
-        if (state.config.num_positions > 0) {
-          float range_radians = (state.config.num_positions - 1) * state.config.position_width_radians;
+        if (num_positions > 0) {
+          float range_radians = (state.config.max_position - state.config.min_position) * state.config.position_width_radians;
           left_bound = PI / 2 + range_radians / 2;
           float right_bound = PI / 2 - range_radians / 2;
           spr_.drawLine(TFT_WIDTH/2 + RADIUS * cosf(left_bound), TFT_HEIGHT/2 - RADIUS * sinf(left_bound), TFT_WIDTH/2 + (RADIUS - 10) * cosf(left_bound), TFT_HEIGHT/2 - (RADIUS - 10) * sinf(left_bound), TFT_WHITE);
@@ -92,18 +94,18 @@ void DisplayTask::run() {
         }
 
         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 raw_angle = left_bound - (state.current_position - state.config.min_position) * state.config.position_width_radians;
         float adjusted_angle = raw_angle - adjusted_sub_position;
 
-        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 (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) {

+ 24 - 24
firmware/src/interface_task.cpp

@@ -26,8 +26,9 @@ Adafruit_VEML7700 veml = Adafruit_VEML7700();
 #endif
 
 static PB_SmartKnobConfig configs[] = {
-    // int32_t num_positions;
     // int32_t position;
+    // int32_t min_position;
+    // int32_t max_position;
     // float position_width_radians;
     // float detent_strength_unit;
     // float endstop_strength_unit;
@@ -36,11 +37,11 @@ static PB_SmartKnobConfig configs[] = {
     // pb_size_t detent_positions_count;
     // int32_t detent_positions[5];
     // float snap_point_bias;
-    // int32_t snap_point_bias_center_position;
 
     {
         0,
         0,
+        -1, // max position < min position indicates no bounds
         10 * PI / 180,
         0,
         1,
@@ -49,11 +50,11 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
-        0,
     },
     {
-        11,
         0,
+        0,
+        10,
         10 * PI / 180,
         0,
         1,
@@ -62,11 +63,11 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
-        0,
     },
     {
-        73,
         0,
+        0,
+        72,
         10 * PI / 180,
         0,
         1,
@@ -75,11 +76,11 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
-        0,
     },
     {
-        2,
         0,
+        0,
+        1,
         60 * PI / 180,
         1,
         1,
@@ -88,10 +89,10 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
-        0,
     },
     {
-        1,
+        0,
+        0,
         0,
         60 * PI / 180,
         0.01,
@@ -101,11 +102,11 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
-        0,
     },
     {
-        256,
         127,
+        0,
+        255,
         1 * PI / 180,
         0,
         1,
@@ -114,11 +115,11 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
-        0,
     },
     {
-        256,
         127,
+        0,
+        255,
         1 * PI / 180,
         1,
         1,
@@ -127,11 +128,11 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
-        0,
     },
     {
-        32,
         0,
+        0,
+        31,
         8.225806452 * PI / 180,
         2,
         1,
@@ -140,11 +141,11 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
-        0,
     },
     {
-        32,
         0,
+        0,
+        31,
         8.225806452 * PI / 180,
         0.2,
         1,
@@ -153,11 +154,11 @@ static PB_SmartKnobConfig configs[] = {
         0,
         {},
         0,
-        0,
     },
     {
-        32,
         0,
+        0,
+        31,
         7 * PI / 180,
         2.5,
         1,
@@ -166,10 +167,10 @@ static PB_SmartKnobConfig configs[] = {
         4,
         {2, 10, 21, 22},
         0,
-        0,
     },
     {
-        13,
+        0,
+        -6,
         6,
         60 * PI / 180,
         1,
@@ -178,8 +179,7 @@ static PB_SmartKnobConfig configs[] = {
         "Return-to-center\nwith detents",
         0,
         {},
-        0.4,
-        6
+        0.4
     },
 };
 

+ 17 - 19
firmware/src/motor_task.cpp

@@ -13,7 +13,7 @@
 // #### 
 // 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 float ZERO_ELECTRICAL_OFFSET = 7.81;
 static const Direction FOC_DIRECTION = Direction::CW;
 static const int MOTOR_POLE_PAIRS = 7;
 // ####
@@ -85,8 +85,9 @@ void MotorTask::run() {
 
     float current_detent_center = motor.shaft_angle;
     PB_SmartKnobConfig config = {
-        .num_positions = 2,
         .position = 0,
+        .min_position = 0,
+        .max_position = 1,
         .position_width_radians = 60 * _PI / 180,
         .detent_strength_unit = 0,
     };
@@ -109,10 +110,6 @@ void MotorTask::run() {
                     break;
                 case CommandType::CONFIG: {
                     // Check new config for validity
-                    if (command.data.config.num_positions < 0) {
-                        log("Ignoring invalid config: num_positions cannot be negative");
-                        break;
-                    }
                     if (command.data.config.detent_strength_unit < 0) {
                         log("Ignoring invalid config: detent_strength_unit cannot be negative");
                         break;
@@ -133,10 +130,6 @@ void MotorTask::run() {
                         log("Ignoring invalid config: snap_point_bias cannot be negative or there is risk of instability");
                         break;
                     }
-                    if (command.data.config.snap_point_bias_center_position < 0 || command.data.config.snap_point_bias_center_position > command.data.config.num_positions || (command.data.config.num_positions > 0 && command.data.config.snap_point_bias_center_position == command.data.config.num_positions)) {
-                        log("Ignoring invalid config: snap_point_bias_center_position out of range");
-                        break;
-                    }
 
                     // Change haptic input mode
                     config = command.data.config;
@@ -144,12 +137,16 @@ void MotorTask::run() {
                         // INT32_MIN indicates no change to position, so restore from latest_config
                         config.position = latest_config.position;
                     }
+                    if (config.position != latest_config.position
+                            || config.position_width_radians != latest_config.position_width_radians) {
+                        // Only adjust the detent center if the position or width has changed
+                        current_detent_center = motor.shaft_angle;
+                        #if SK_INVERT_ROTATION
+                            current_detent_center = -motor.shaft_angle;
+                        #endif
+                    }
                     latest_config = 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.
@@ -214,14 +211,15 @@ void MotorTask::run() {
 
         float snap_point_radians = config.position_width_radians * config.snap_point;
         float bias_radians = config.position_width_radians * config.snap_point_bias;
-        float snap_point_radians_decrease = snap_point_radians + (config.position <= config.snap_point_bias_center_position ? bias_radians : -bias_radians);
-        float snap_point_radians_increase = -snap_point_radians + (config.position >= config.snap_point_bias_center_position ? -bias_radians : bias_radians); 
+        float snap_point_radians_decrease = snap_point_radians + (config.position <= 0 ? bias_radians : -bias_radians);
+        float snap_point_radians_increase = -snap_point_radians + (config.position >= 0 ? -bias_radians : bias_radians); 
 
-        if (angle_to_detent_center > snap_point_radians_decrease && (config.num_positions <= 0 || config.position > 0)) {
+        int32_t num_positions = config.max_position - config.min_position + 1;
+        if (angle_to_detent_center > snap_point_radians_decrease && (num_positions <= 0 || config.position > config.min_position)) {
             current_detent_center += config.position_width_radians;
             angle_to_detent_center -= config.position_width_radians;
             config.position--;
-        } else if (angle_to_detent_center < snap_point_radians_increase && (config.num_positions <= 0 || config.position < config.num_positions - 1)) {
+        } else if (angle_to_detent_center < snap_point_radians_increase && (num_positions <= 0 || config.position < config.max_position)) {
             current_detent_center -= config.position_width_radians;
             angle_to_detent_center += config.position_width_radians;
             config.position++;
@@ -232,7 +230,7 @@ void MotorTask::run() {
             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 && config.position == config.min_position) || (angle_to_detent_center < 0 && config.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;
 

+ 24 - 24
firmware/src/proto_gen/smartknob.pb.h

@@ -23,8 +23,9 @@ typedef struct _PB_Log {
 } PB_Log;
 
 typedef struct _PB_SmartKnobConfig {
-    int32_t num_positions;
     int32_t position;
+    int32_t min_position;
+    int32_t max_position;
     float position_width_radians;
     float detent_strength_unit;
     float endstop_strength_unit;
@@ -33,7 +34,6 @@ typedef struct _PB_SmartKnobConfig {
     pb_size_t detent_positions_count;
     int32_t detent_positions[5];
     float snap_point_bias;
-    int32_t snap_point_bias_center_position;
 } PB_SmartKnobConfig;
 
 typedef struct _PB_SmartKnobState {
@@ -74,29 +74,29 @@ extern "C" {
 #define PB_Log_init_default                      {""}
 #define PB_SmartKnobState_init_default           {0, 0, false, PB_SmartKnobConfig_init_default}
 #define PB_ToSmartknob_init_default              {0, 0, {PB_RequestState_init_default}}
-#define PB_SmartKnobConfig_init_default          {0, 0, 0, 0, 0, 0, "", 0, {0, 0, 0, 0, 0}, 0, 0}
+#define PB_SmartKnobConfig_init_default          {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_Ack_init_zero                         {0}
 #define PB_Log_init_zero                         {""}
 #define PB_SmartKnobState_init_zero              {0, 0, false, PB_SmartKnobConfig_init_zero}
 #define PB_ToSmartknob_init_zero                 {0, 0, {PB_RequestState_init_zero}}
-#define PB_SmartKnobConfig_init_zero             {0, 0, 0, 0, 0, 0, "", 0, {0, 0, 0, 0, 0}, 0, 0}
+#define PB_SmartKnobConfig_init_zero             {0, 0, 0, 0, 0, 0, 0, "", 0, {0, 0, 0, 0, 0}, 0}
 #define PB_RequestState_init_zero                {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_detent_positions_tag  8
-#define PB_SmartKnobConfig_snap_point_bias_tag   9
-#define PB_SmartKnobConfig_snap_point_bias_center_position_tag 10
+#define PB_SmartKnobConfig_position_tag          1
+#define PB_SmartKnobConfig_min_position_tag      2
+#define PB_SmartKnobConfig_max_position_tag      3
+#define PB_SmartKnobConfig_position_width_radians_tag 4
+#define PB_SmartKnobConfig_detent_strength_unit_tag 5
+#define PB_SmartKnobConfig_endstop_strength_unit_tag 6
+#define PB_SmartKnobConfig_snap_point_tag        7
+#define PB_SmartKnobConfig_text_tag              8
+#define PB_SmartKnobConfig_detent_positions_tag  9
+#define PB_SmartKnobConfig_snap_point_bias_tag   10
 #define PB_SmartKnobState_current_position_tag   1
 #define PB_SmartKnobState_sub_position_unit_tag  2
 #define PB_SmartKnobState_config_tag             3
@@ -146,16 +146,16 @@ X(a, STATIC,   ONEOF,    MESSAGE,  (payload,smartknob_config,payload.smartknob_c
 #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,   REPEATED, INT32,    detent_positions,   8) \
-X(a, STATIC,   SINGULAR, FLOAT,    snap_point_bias,   9) \
-X(a, STATIC,   SINGULAR, INT32,    snap_point_bias_center_position,  10)
+X(a, STATIC,   SINGULAR, INT32,    position,          1) \
+X(a, STATIC,   SINGULAR, INT32,    min_position,      2) \
+X(a, STATIC,   SINGULAR, INT32,    max_position,      3) \
+X(a, STATIC,   SINGULAR, FLOAT,    position_width_radians,   4) \
+X(a, STATIC,   SINGULAR, FLOAT,    detent_strength_unit,   5) \
+X(a, STATIC,   SINGULAR, FLOAT,    endstop_strength_unit,   6) \
+X(a, STATIC,   SINGULAR, FLOAT,    snap_point,        7) \
+X(a, STATIC,   SINGULAR, STRING,   text,              8) \
+X(a, STATIC,   REPEATED, INT32,    detent_positions,   9) \
+X(a, STATIC,   SINGULAR, FLOAT,    snap_point_bias,  10)
 #define PB_SmartKnobConfig_CALLBACK NULL
 #define PB_SmartKnobConfig_DEFAULT NULL
 

+ 6 - 2
firmware/src/proto_helpers.h

@@ -5,11 +5,15 @@
 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.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;
+        && 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) {

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

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

+ 10 - 10
proto/smartknob.proto

@@ -42,16 +42,16 @@ message ToSmartknob {
 }
 
 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];
-    repeated int32 detent_positions = 8 [(nanopb).max_count = 5];
-    float snap_point_bias = 9;
-    int32 snap_point_bias_center_position = 10;
+    int32 position = 1;
+    int32 min_position = 2;
+    int32 max_position = 3;
+    float position_width_radians = 4;
+    float detent_strength_unit = 5;
+    float endstop_strength_unit = 6;
+    float snap_point = 7;
+    string text = 8 [(nanopb).max_length = 50];
+    repeated int32 detent_positions = 9 [(nanopb).max_count = 5];
+    float snap_point_bias = 10;
 }
 
 message RequestState {}

+ 3 - 3
software/js/package.json

@@ -15,11 +15,11 @@
   "author": "",
   "license": "Apache-2.0",
   "workspaces": [
+    "packages/smartknobjs-proto",
+    "packages/smartknobjs",
     "packages/demo-backend",
     "packages/demo-frontend",
-    "packages/example",
-    "packages/smartknobjs-proto",
-    "packages/smartknobjs"
+    "packages/example"
   ],
   "devDependencies": {
     "concurrently": "^7.6.0",

+ 63 - 83
software/js/packages/demo-backend/src/index.ts

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

+ 47 - 20
software/js/packages/demo-frontend/src/App.tsx

@@ -8,10 +8,13 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
 import {PB} from 'smartknobjs-proto'
 import {VideoInfo} from './types'
 import {Card, CardActions, CardContent} from '@mui/material'
-import {exhaustiveCheck, lerp, NoUndefinedField} from './util'
+import {exhaustiveCheck, INT32_MIN, lerp, NoUndefinedField} from './util'
 
 const socket = io()
 
+// const MIN_ZOOM = 0.01
+// const MAX_ZOOM = 40
+
 enum Mode {
     Scroll = 'Scroll',
     Frames = 'Frames',
@@ -22,6 +25,7 @@ type State = {
     mode: Mode
     playbackSpeed: number
     currentFrame: number
+    zoomTimelinePixelsPerFrame: number
 }
 
 export type AppProps = {
@@ -35,6 +39,7 @@ export const App: React.FC<AppProps> = ({info}) => {
         mode: Mode.Scroll,
         playbackSpeed: 0,
         currentFrame: 0,
+        zoomTimelinePixelsPerFrame: 0.1,
     })
 
     const pushConfig = (state: State) => {
@@ -42,8 +47,9 @@ export const App: React.FC<AppProps> = ({info}) => {
         if (state.mode === Mode.Scroll) {
             // TODO
             config = PB.SmartKnobConfig.create({
-                numPositions: 32,
                 position: 0,
+                minPosition: 0,
+                maxPosition: 31,
                 positionWidthRadians: (7 * Math.PI) / 180,
                 detentStrengthUnit: 2.5,
                 endstopStrengthUnit: 1,
@@ -51,12 +57,12 @@ export const App: React.FC<AppProps> = ({info}) => {
                 text: Mode.Scroll,
                 detentPositions: [0, 10, 21, 22],
                 snapPointBias: 0,
-                snapPointBiasCenterPosition: 0,
             })
         } else if (state.mode === Mode.Frames) {
             config = PB.SmartKnobConfig.create({
-                numPositions: info.totalFrames,
                 position: state.currentFrame,
+                minPosition: 0,
+                maxPosition: info.totalFrames - 1,
                 positionWidthRadians: (1 * Math.PI) / 180,
                 detentStrengthUnit: 1,
                 endstopStrengthUnit: 1,
@@ -64,12 +70,12 @@ export const App: React.FC<AppProps> = ({info}) => {
                 text: Mode.Frames,
                 detentPositions: [],
                 snapPointBias: 0,
-                snapPointBiasCenterPosition: 0,
             })
         } else if (state.mode === Mode.Speed) {
             config = PB.SmartKnobConfig.create({
-                numPositions: 13,
-                position: 6 + Math.trunc(state.playbackSpeed),
+                position: state.playbackSpeed === 0 ? 0 : INT32_MIN,
+                minPosition: state.currentFrame === 0 ? 0 : -6,
+                maxPosition: state.currentFrame === info.totalFrames - 1 ? 0 : 6,
                 positionWidthRadians: (60 * Math.PI) / 180,
                 detentStrengthUnit: 1,
                 endstopStrengthUnit: 1,
@@ -77,7 +83,6 @@ export const App: React.FC<AppProps> = ({info}) => {
                 text: Mode.Speed,
                 detentPositions: [],
                 snapPointBias: 0.4,
-                snapPointBiasCenterPosition: 6,
             })
         } else {
             throw exhaustiveCheck(state.mode)
@@ -96,24 +101,43 @@ export const App: React.FC<AppProps> = ({info}) => {
     }
 
     useEffect(() => {
+        const refreshInterval = 20
         const fps = info.frameRate * derivedState.playbackSpeed
-        const msPerFrame = 1000 / fps
         if (derivedState.mode === Mode.Speed && fps !== 0) {
             const timer = setInterval(() => {
                 setCurrentFrame((oldFrame) => {
-                    const newFrame = oldFrame + Math.sign(derivedState.playbackSpeed)
+                    const newFrame = oldFrame + (fps * refreshInterval) / 1000
+
+                    const oldFrameTrunc = Math.trunc(oldFrame)
+                    const newFrameTrunc = Math.trunc(newFrame)
+
                     if (newFrame < 0 || newFrame >= info.totalFrames) {
+                        const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
+                        // TODO: figure out how to avoid sending this config repeatedly
                         pushConfig({
-                            mode: derivedState.mode,
+                            mode: Mode.Speed,
                             playbackSpeed: 0,
-                            currentFrame: -1, // unused
+                            currentFrame: Math.trunc(clampedNewFrame),
+                            zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
                         })
-                        return oldFrame
+                        return clampedNewFrame
                     } else {
+                        if (
+                            (oldFrameTrunc === 0 && newFrameTrunc > 0) ||
+                            (oldFrameTrunc === info.totalFrames - 1 && newFrameTrunc < info.totalFrames - 1)
+                        ) {
+                            // If we've left a boundary condition, push a config to reset the bounds
+                            pushConfig({
+                                mode: derivedState.mode,
+                                playbackSpeed: 0,
+                                currentFrame: newFrameTrunc,
+                                zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
+                            })
+                        }
                         return newFrame
                     }
                 })
-            }, msPerFrame)
+            }, refreshInterval)
             return () => clearInterval(timer)
         }
     }, [derivedState.mode, derivedState.playbackSpeed, info.totalFrames, info.frameRate])
@@ -121,6 +145,7 @@ export const App: React.FC<AppProps> = ({info}) => {
     useEffect(() => {
         socket.on('connect', () => {
             setIsConnected(true)
+            pushConfig(derivedState)
         })
 
         socket.on('disconnect', () => {
@@ -139,16 +164,18 @@ export const App: React.FC<AppProps> = ({info}) => {
                     return {
                         mode: Mode.Scroll,
                         playbackSpeed: 0,
-                        currentFrame: cur.currentFrame, // TODO - map from state
+                        currentFrame: cur.currentFrame, // TODO - map from state and zoom
+                        zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
                     }
                 } else if (modeText === Mode.Frames) {
                     return {
                         mode: Mode.Frames,
                         playbackSpeed: 0,
                         currentFrame: stateObj.currentPosition ?? 0,
+                        zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
                     }
                 } else if (modeText === Mode.Speed) {
-                    const normalizedWholeValue = stateObj.currentPosition - stateObj.config.snapPointBiasCenterPosition
+                    const normalizedWholeValue = stateObj.currentPosition
                     const normalizedFractional =
                         Math.sign(stateObj.subPositionUnit) *
                         lerp(stateObj.subPositionUnit * Math.sign(stateObj.subPositionUnit), 0.1, 0.9, 0, 1)
@@ -158,6 +185,7 @@ export const App: React.FC<AppProps> = ({info}) => {
                         mode: Mode.Speed,
                         playbackSpeed: speed,
                         currentFrame: cur.currentFrame,
+                        zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
                     }
                 }
                 return cur
@@ -185,9 +213,8 @@ export const App: React.FC<AppProps> = ({info}) => {
                                 return
                             }
                             pushConfig({
+                                ...derivedState,
                                 mode: value,
-                                currentFrame: derivedState.currentFrame,
-                                playbackSpeed: derivedState.playbackSpeed,
                             })
                         }}
                         aria-label="Mode"
@@ -199,9 +226,9 @@ export const App: React.FC<AppProps> = ({info}) => {
                         ))}
                     </ToggleButtonGroup>
                     <Typography component="h1" variant="h5">
-                        Frame {derivedState.currentFrame} / {info.totalFrames - 1}
+                        Frame {Math.trunc(derivedState.currentFrame)} / {info.totalFrames - 1}
                         <br />
-                        Speed {derivedState.playbackSpeed}
+                        Speed {Math.trunc(derivedState.playbackSpeed * 10) / 10}
                     </Typography>
                 </CardContent>
                 <CardActions>

+ 2 - 0
software/js/packages/demo-frontend/src/util.ts

@@ -17,3 +17,5 @@ export const lerp = (value: number, inMin: number, inMax: number, min: number, m
 export type NoUndefinedField<T> = {
     [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>>
 }
+
+export const INT32_MIN = -2147483648

+ 4 - 2
software/js/packages/example/src/index.ts

@@ -9,7 +9,8 @@ const main = async () => {
         // 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.vendorId?.toLowerCase() === '1a86'.toLowerCase() &&
+            portInfo.productId?.toLowerCase() === '7523'.toLowerCase()
             // && portInfo.serialNumber === 'DEADBEEF'
         )
     })
@@ -48,8 +49,9 @@ const main = async () => {
         PB.SmartKnobConfig.create({
             detentStrengthUnit: 1,
             endstopStrengthUnit: 1,
-            numPositions: 5,
             position: 0,
+            minPosition: 0,
+            maxPosition: 4,
             positionWidthRadians: (10 * Math.PI) / 180,
             snapPoint: 1.1,
             text: 'From TS!',

+ 1 - 9
software/js/packages/smartknobjs/src/index.ts

@@ -49,15 +49,7 @@ export class SmartKnob {
             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!',
-        })
+        this.currentConfig = PB.SmartKnobConfig.create({})
 
         this.lastNonce = Math.floor(Math.random() * (2 ^ (32 - 1)))
     }