2 Commit-ok 2ecefd1e10 ... b0d2261687

Szerző SHA1 Üzenet Dátum
  Scott Bezek b0d2261687 Fix non-positional config changes and get dynamic detentPositions mostly working 3 éve
  Scott Bezek 8a54c0819f Mostly working frame and speed modes, breaking proto change 3 éve

+ 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
     },
 };
 

+ 23 - 25
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.61;
 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,
     };
@@ -95,8 +96,6 @@ void MotorTask::run() {
     uint32_t last_idle_start = 0;
     uint32_t last_publish = 0;
 
-    PB_SmartKnobConfig latest_config = config;
-
     while (1) {
         motor.loopFOC();
 
@@ -109,10 +108,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,23 +128,25 @@ 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;
-                    if (config.position == INT32_MIN) {
+                    PB_SmartKnobConfig newConfig = command.data.config;
+                    if (newConfig.position == INT32_MIN) {
                         // INT32_MIN indicates no change to position, so restore from latest_config
-                        config.position = latest_config.position;
+                        log("maintaining position");
+                        newConfig.position = config.position;
+                    }
+                    if (newConfig.position != config.position
+                            || newConfig.position_width_radians != config.position_width_radians) {
+                        // Only adjust the detent center if the position or width has changed
+                        log("adjusting detent center");
+                        current_detent_center = motor.shaft_angle;
+                        #if SK_INVERT_ROTATION
+                            current_detent_center = -motor.shaft_angle;
+                        #endif
                     }
-                    latest_config = config;
+                    config = newConfig;
                     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 {}

+ 4 - 4
software/js/package-lock.json

@@ -9,11 +9,11 @@
       "version": "0.1.0",
       "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",
@@ -21796,7 +21796,7 @@
         "@testing-library/react": "^13.4.0",
         "@testing-library/user-event": "^13.5.0",
         "@types/jest": "^27.5.2",
-        "@types/lodash": "*",
+        "@types/lodash": "^4.14.191",
         "@types/node": "^16.18.4",
         "@types/react": "^18.0.25",
         "@types/react-dom": "^18.0.9",

+ 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()

+ 286 - 112
software/js/packages/demo-frontend/src/App.tsx

@@ -1,4 +1,4 @@
-import React, {useEffect, useState} from 'react'
+import React, {useEffect, useMemo, useRef, useState} from 'react'
 import './App.css'
 import io from 'socket.io-client'
 import Typography from '@mui/material/Typography'
@@ -7,11 +7,17 @@ import ToggleButton from '@mui/material/ToggleButton'
 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 {Card, CardContent} from '@mui/material'
+import {exhaustiveCheck, findNClosest, INT32_MIN, lerp, NoUndefinedField} from './util'
+import {groupBy, parseInt} from 'lodash'
 
 const socket = io()
 
+const MIN_ZOOM = 0.01
+const MAX_ZOOM = 60
+
+const PIXELS_PER_POSITION = 10
+
 enum Mode {
     Scroll = 'Scroll',
     Frames = 'Frames',
@@ -22,6 +28,7 @@ type State = {
     mode: Mode
     playbackSpeed: number
     currentFrame: number
+    zoomTimelinePixelsPerFrame: number
 }
 
 export type AppProps = {
@@ -29,47 +36,123 @@ export type AppProps = {
 }
 export const App: React.FC<AppProps> = ({info}) => {
     const [isConnected, setIsConnected] = useState(socket.connected)
-    const [state, setState] = useState<string>('')
-
+    const [state, setState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
+        PB.SmartKnobState.toObject(PB.SmartKnobState.create({config: PB.SmartKnobConfig.create()}), {
+            defaults: true,
+        }) as NoUndefinedField<PB.ISmartKnobState>,
+    )
     const [derivedState, setDerivedState] = useState<State>({
         mode: Mode.Scroll,
         playbackSpeed: 0,
         currentFrame: 0,
+        zoomTimelinePixelsPerFrame: 0.1,
     })
 
+    useMemo(() => {
+        setDerivedState((cur) => {
+            const modeText = state.config.text
+            if (modeText === Mode.Scroll) {
+                const rawFrame = Math.trunc(
+                    ((state.currentPosition + state.subPositionUnit) * PIXELS_PER_POSITION) /
+                        cur.zoomTimelinePixelsPerFrame,
+                )
+                return {
+                    mode: Mode.Scroll,
+                    playbackSpeed: 0,
+                    currentFrame: Math.min(Math.max(rawFrame, 0), info.totalFrames - 1),
+                    zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
+                }
+            } else if (modeText === Mode.Frames) {
+                return {
+                    mode: Mode.Frames,
+                    playbackSpeed: 0,
+                    currentFrame: state.currentPosition ?? 0,
+                    zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
+                }
+            } else if (modeText === Mode.Speed) {
+                const normalizedWholeValue = state.currentPosition
+                const normalizedFractional =
+                    Math.sign(state.subPositionUnit) *
+                    lerp(state.subPositionUnit * Math.sign(state.subPositionUnit), 0.1, 0.9, 0, 1)
+                const normalized = normalizedWholeValue + normalizedFractional
+                const speed = Math.sign(normalized) * Math.pow(2, Math.abs(normalized) - 1)
+                return {
+                    mode: Mode.Speed,
+                    playbackSpeed: speed,
+                    currentFrame: cur.currentFrame,
+                    zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
+                }
+            }
+            return cur
+        })
+    }, [state.config.text, state.currentPosition, state.subPositionUnit])
+
+    const totalPositions = Math.ceil((info.totalFrames * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
+    const detentPositions = useMemo(() => {
+        // Always include the first and last positions at detents
+        const positionsToFrames = groupBy([0, ...info.boundaryFrames, info.totalFrames - 1], (frame) =>
+            Math.round((frame * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION),
+        )
+        console.log(JSON.stringify(positionsToFrames))
+        return positionsToFrames
+    }, [info.boundaryFrames, totalPositions, derivedState.zoomTimelinePixelsPerFrame])
+
+    // Continuous config updates for scrolling, to update detent positions
+    useMemo(() => {
+        if (derivedState.mode === Mode.Scroll) {
+            const config = PB.SmartKnobConfig.create({
+                position: INT32_MIN,
+                minPosition: 0,
+                maxPosition: totalPositions - 1,
+                positionWidthRadians: (8 * Math.PI) / 180,
+                detentStrengthUnit: 2.5,
+                endstopStrengthUnit: 1,
+                snapPoint: 0.7,
+                text: Mode.Scroll,
+                detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), state.currentPosition, 5),
+                snapPointBias: 0,
+            })
+            socket.emit('set_config', config)
+        }
+    }, [derivedState.mode, derivedState.zoomTimelinePixelsPerFrame, detentPositions, state.currentPosition])
+
+    // For one-off config pushes, e.g. mode changes
     const pushConfig = (state: State) => {
         let config: PB.SmartKnobConfig
         if (state.mode === Mode.Scroll) {
-            // TODO
+            const position = Math.trunc((state.currentFrame * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
             config = PB.SmartKnobConfig.create({
-                numPositions: 32,
-                position: 0,
-                positionWidthRadians: (7 * Math.PI) / 180,
+                position,
+                minPosition: 0,
+                maxPosition: Math.trunc(
+                    ((info.totalFrames - 1) * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
+                ),
+                positionWidthRadians: (8 * Math.PI) / 180,
                 detentStrengthUnit: 2.5,
                 endstopStrengthUnit: 1,
                 snapPoint: 0.7,
                 text: Mode.Scroll,
-                detentPositions: [0, 10, 21, 22],
+                detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), position, 5),
                 snapPointBias: 0,
-                snapPointBiasCenterPosition: 0,
             })
         } else if (state.mode === Mode.Frames) {
             config = PB.SmartKnobConfig.create({
-                numPositions: info.totalFrames,
                 position: state.currentFrame,
-                positionWidthRadians: (1 * Math.PI) / 180,
+                minPosition: 0,
+                maxPosition: info.totalFrames - 1,
+                positionWidthRadians: (1.5 * Math.PI) / 180,
                 detentStrengthUnit: 1,
                 endstopStrengthUnit: 1,
                 snapPoint: 1.1,
                 text: Mode.Frames,
                 detentPositions: [],
                 snapPointBias: 0,
-                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 +160,6 @@ export const App: React.FC<AppProps> = ({info}) => {
                 text: Mode.Speed,
                 detentPositions: [],
                 snapPointBias: 0.4,
-                snapPointBiasCenterPosition: 6,
             })
         } else {
             throw exhaustiveCheck(state.mode)
@@ -95,32 +177,56 @@ export const App: React.FC<AppProps> = ({info}) => {
         })
     }
 
+    // Timer for speed-based playback
     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) {
-                        pushConfig({
-                            mode: derivedState.mode,
-                            playbackSpeed: 0,
-                            currentFrame: -1, // unused
-                        })
-                        return oldFrame
+                        const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
+                        if (oldFrame !== clampedNewFrame) {
+                            // If we've hit a boundary, push a config to set the bounds
+                            pushConfig({
+                                mode: Mode.Speed,
+                                playbackSpeed: 0,
+                                currentFrame: Math.trunc(clampedNewFrame),
+                                zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
+                            })
+                        }
+                        return clampedNewFrame
                     } else {
+                        if (
+                            (oldFrameTrunc === 0 && newFrameTrunc > 0) ||
+                            (oldFrameTrunc === info.totalFrames - 1 && newFrameTrunc < info.totalFrames - 1)
+                        ) {
+                            // If we've left a boundary condition, push a config to reset the bounds
+                            pushConfig({
+                                mode: derivedState.mode,
+                                playbackSpeed: 0,
+                                currentFrame: newFrameTrunc,
+                                zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
+                            })
+                        }
                         return newFrame
                     }
                 })
-            }, msPerFrame)
+            }, refreshInterval)
             return () => clearInterval(timer)
         }
     }, [derivedState.mode, derivedState.playbackSpeed, info.totalFrames, info.frameRate])
 
+    // Socket.io subscription
     useEffect(() => {
         socket.on('connect', () => {
             setIsConnected(true)
+            pushConfig(derivedState)
         })
 
         socket.on('disconnect', () => {
@@ -132,36 +238,7 @@ export const App: React.FC<AppProps> = ({info}) => {
             const stateObj = PB.SmartKnobState.toObject(state, {
                 defaults: true,
             }) as NoUndefinedField<PB.ISmartKnobState>
-            setState(JSON.stringify(stateObj, undefined, 4))
-            setDerivedState((cur) => {
-                const modeText = stateObj.config.text
-                if (modeText === Mode.Scroll) {
-                    return {
-                        mode: Mode.Scroll,
-                        playbackSpeed: 0,
-                        currentFrame: cur.currentFrame, // TODO - map from state
-                    }
-                } else if (modeText === Mode.Frames) {
-                    return {
-                        mode: Mode.Frames,
-                        playbackSpeed: 0,
-                        currentFrame: stateObj.currentPosition ?? 0,
-                    }
-                } else if (modeText === Mode.Speed) {
-                    const normalizedWholeValue = stateObj.currentPosition - stateObj.config.snapPointBiasCenterPosition
-                    const normalizedFractional =
-                        Math.sign(stateObj.subPositionUnit) *
-                        lerp(stateObj.subPositionUnit * Math.sign(stateObj.subPositionUnit), 0.1, 0.9, 0, 1)
-                    const normalized = normalizedWholeValue + normalizedFractional
-                    const speed = Math.sign(normalized) * Math.pow(2, Math.abs(normalized) - 1)
-                    return {
-                        mode: Mode.Speed,
-                        playbackSpeed: speed,
-                        currentFrame: cur.currentFrame,
-                    }
-                }
-                return cur
-            })
+            setState(stateObj)
         })
         return () => {
             socket.off('connect')
@@ -170,65 +247,162 @@ export const App: React.FC<AppProps> = ({info}) => {
         }
     }, [])
     return (
-        <Container component="main" maxWidth="sm">
-            <Card>
-                <CardContent>
-                    <Typography component="h1" variant="h5">
-                        Video Playback Control Demo
-                    </Typography>
-                    <ToggleButtonGroup
-                        color="primary"
-                        value={derivedState.mode}
-                        exclusive
-                        onChange={(e, value: Mode | null) => {
-                            if (value === null) {
-                                return
-                            }
-                            pushConfig({
-                                mode: value,
-                                currentFrame: derivedState.currentFrame,
-                                playbackSpeed: derivedState.playbackSpeed,
-                            })
-                        }}
-                        aria-label="Mode"
-                    >
-                        {Object.keys(Mode).map((mode) => (
-                            <ToggleButton value={mode} key={mode}>
-                                {mode}
-                            </ToggleButton>
-                        ))}
-                    </ToggleButtonGroup>
-                    <Typography component="h1" variant="h5">
-                        Frame {derivedState.currentFrame} / {info.totalFrames - 1}
-                        <br />
-                        Speed {derivedState.playbackSpeed}
-                    </Typography>
-                </CardContent>
-                <CardActions>
-                    {/* {derivedState.mode === Mode.Speed && (
-                        <Button
-                            size="small"
-                            onClick={() => {
-                                if (derivedState.playbackSpeed !== 0) {
-                                    setPlaybackSpeed(0)
-                                } else {
-                                    setPlaybackSpeed(1)
+        <>
+            <Container component="main" maxWidth="md">
+                <Card>
+                    <CardContent>
+                        <Typography component="h1" variant="h5">
+                            Video Playback Control Demo
+                        </Typography>
+                        {isConnected || (
+                            <Typography component="h6" variant="h6">
+                                [Not connected]
+                            </Typography>
+                        )}
+                        <ToggleButtonGroup
+                            color="primary"
+                            value={derivedState.mode}
+                            exclusive
+                            onChange={(e, value: Mode | null) => {
+                                if (value === null) {
+                                    return
                                 }
-                                pushCurrentConfig()
+                                pushConfig({
+                                    ...derivedState,
+                                    mode: value,
+                                })
                             }}
+                            aria-label="Mode"
                         >
-                            Play
-                        </Button>
-                    )} */}
-                </CardActions>
-            </Card>
-            <Card>
-                <CardContent>
-                    <div>{isConnected ? <code>{state}</code> : <p>Not connected</p>}</div>
-                </CardContent>
-            </Card>
-        </Container>
+                            {Object.keys(Mode).map((mode) => (
+                                <ToggleButton value={mode} key={mode}>
+                                    {mode}
+                                </ToggleButton>
+                            ))}
+                        </ToggleButtonGroup>
+                        <Typography>
+                            Frame {Math.trunc(derivedState.currentFrame)} / {info.totalFrames - 1}
+                            <br />
+                            Speed {Math.trunc(derivedState.playbackSpeed * 10) / 10}
+                        </Typography>
+                    </CardContent>
+                </Card>
+                <Timeline
+                    info={info}
+                    currentFrame={derivedState.currentFrame}
+                    zoomTimelinePixelsPerFrame={derivedState.zoomTimelinePixelsPerFrame}
+                    adjustZoom={(factor) => {
+                        setDerivedState((cur) => {
+                            const newZoom = Math.min(
+                                Math.max(cur.zoomTimelinePixelsPerFrame * factor, MIN_ZOOM),
+                                MAX_ZOOM,
+                            )
+                            console.log(factor, newZoom)
+                            return {
+                                ...cur,
+                                zoomTimelinePixelsPerFrame: newZoom,
+                            }
+                        })
+                    }}
+                />
+                <Card>
+                    <CardContent>
+                        <div>{JSON.stringify(detentPositions)}</div>
+                    </CardContent>
+                </Card>
+            </Container>
+        </>
     )
 }
 
-export default App
+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>
+    )
+}

+ 2 - 2
software/js/packages/demo-frontend/src/index.tsx

@@ -1,7 +1,7 @@
 import React from 'react'
 import ReactDOM from 'react-dom/client'
 import './index.css'
-import App from './App'
+import {App} from './App'
 import reportWebVitals from './reportWebVitals'
 import '@fontsource/roboto/300.css'
 import '@fontsource/roboto/400.css'
@@ -18,7 +18,7 @@ const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
 const info: VideoInfo = {
     totalFrames: 30 * 60 * 5,
     frameRate: 30,
-    boundaryFrames: [1234, 1290, 3000],
+    boundaryFrames: [312, 400, 1234, 1290, 3000, 4000],
 }
 root.render(
     <React.StrictMode>

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

@@ -17,3 +17,15 @@ 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
+
+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)
+}

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