Final Project

My Final Project, a smart knob that controls PC volume and media playback. Build on a PCB fully designed and built by me.

All files used in this project can be assessed here.

https://github.com/7HE-W0R1D/HCDE-439/tree/master/static/Final

arduino picture

Proposal

Project Concept/Motivation

I would like to make a smart knob that displays and controls my PC's volume.

Anticipated Bill of Materials

Concept

A smart knob is designed to allow PC users to quickly check and adjust their PC's volumn. A rotary encoder is used to detect the user's scrolling actions. It is also equipped with an OLED screen that displays information about the current volume level or multimedia actions. The knob is also designed with a multimedia button that can be used to play, pause, mute, or skip through music or videos.

The smart knob is a convenient and efficient way to adjust the volume of your PC without having to use your mouse or keyboard. It provides a tactile and intuitive interface that allows for quick and precise adjustments, making it ideal for both casual users and competitive gamers who need to fully focus on their screens. With its geeky design, the smart knob is a stylish addition to any desk setup.

Actual Bill of Materials

Bill of Materials
Amount MN (manufacturer number) MPN (manufacturer part number)
1 Arduino Pro Micro type Arduino Pro Micro (Clone); variant type-c
1 Ceramic Capacitor package 100 mil [THT, multilayer]; voltage 10V; capacitance 22pF
2 Ceramic Capacitor package 100 mil [THT, multilayer]; voltage 35V; capacitance 100nF
1 Panasonic EVQ-V5B00215B package THT;
1 Generic female header - 2 pins package THT; pins 2; pin spacing 0.1in (2.54mm); form ♀ (female); hole size 1.0mm,0.508mm; row single
1 OLED 128x64 I2C Monochrome Display From Amazon; I2C
2 5.1k Ohm Resistor package 1206 [SMD]; tolerance ±5%; resistance 5.1k Ohm; power 0.25
1 1k Ohm Resistor package 1206 [SMD]; tolerance ±5%; resistance 1k Ohm; power 0.25
2 10k Ohm Resistor package 1206 [SMD]; tolerance ±5%; resistance 10k Ohm; power 0.25W
2 10k Ohm Resistor package THT; tolerance ±5%; pin spacing 400 mil; resistance 10k Ohm; bands 5; power 0.25
1 Pushbutton switch status Released; package [THT]

Why Arduino Pro Micro

I chose to use the Arduino pro micro as my microcontroller. The Arduino Pro Micro is a compact and versatile microcontroller board that is ideal for a wide range of projects. Its small size, roughly the size of a standard USB flash drive, makes it easy to integrate into my smart knob project where space is limited. The board features a USB-C port, which allows for fast and reliable data transfer and also provides power to the board. So I don't have to worry about making about USB module in my design.

One of the key benefits of the Arduino Pro Micro is its ability to act as a USB HID (Human Interface Device). This means that it can be used to emulate a keyboard, mouse, or another input device, allowing it to control other devices or software applications. This feature makes the board particularly useful for me as I need it to act as a keyboard to send media commands like "volume up", "volume down", "mute", and so on.

In addition to its USB HID capabilities, the Arduino Pro Micro also has a wide range of inputs and outputs, including digital and analog pins, PWM outputs, and serial communication ports. This makes it a versatile and adaptable platform that allows me to attach any peripherals to it without worrying about running out of pins.

Overall, the Arduino Pro Micro is a powerful and flexible microcontroller board that is ideal for my smart knob project where space is limited and USB HID functionality is required.

Schematic

Treasure Maps

My schematic is similar to my assignment 4, which also features the Arduino, an OLED screen, a button, and a rotary encoder. However, there are also a couple of differences. The biggest change is the MCU, which I changed from an Arduino UNO to an Arduino Pro Micro Type-C. Since it’s a different board, the wiring changed. The IIC bus is on pin 2 and pin 3 for the Arduino Pro Micro. They are connected, instead of the A4 and A5 on the UNO, to the OLED screen, along with two 5.1K Ohm pull-up resistors. Another change is that a Panasonic EVQ-V5B00215B hollow rotary encoder replaced the rotary encoder module included in the kit. According to its datasheet, I included 4 * 10k Ohm resistors and 2 * 0.01uF capacitors to allow it to properly function. There's also a 22pF capacitor between the VCC and the GND pin to ease out spikes in currents.

Header Pin as Reset Button

Sometimes the Arduino becomes unresponsive when uploading code. The official solution is to hold reset button then uploading. However, to keep the size of my PCB, I cannot fit a button and a resistor. I replaced them with a simple 2 pin header connector. When I need the reset button, I can connect the header pins to a button on a breadboard.

Better Power Supply

The IIC bus communicates rapidly with the arduino, if there are noise, sometimes the Arduino behaves weirdly or freezes. To compensate for that, I put a 22pF capacitor between the power rails of the arduino, the capacitors can ease the fluctuation of the power on the breadboard.

Better I2C Bus Performance

According to the web, two 4.7k ~ 10k Ohm resistors are required to pull-up the SCK(SCL) and SDA lines in the I2C bus. These resistors can let to better performance and more resistance to noises. I chose two 5.1k Ohm resistors that are available in the kit.

Hardware Implementation

Above the blueprint

The above image shows the actual Arduino circuit build on a etched PCB. There are instructions on the top. Protections for the PCB are installed in the bottom.

Making the PCB

Engineering Fun

To make the project more fun, I decided to home made the PCB from scratch. Some more detailed tutorials:

But here's my steps and some tips (a cheaper, more home-approachable version)

Materials Used

General Steps

Design the PCB

I use fritzing to design the PCB, it is a once free electronic designing app for hobbyists. I've been using it for a few years and it has good compatibility with Arduino parts. I iterated my PCB with several versions and the final one is like this

Print the Design
Protect the Routes on the Copper Plate

There are two ways to do this: toner transfer method and the photosensitive method. Due to my limitations to simple equipment and school printers, I have to choose photosensitive method, which is more complex but has higher resolution.

Prepare the Copper Plate
Transfer the Routes
Remove the Copper
Remove Protective Film
Prepare for Soldering
Tips

Software Implementation

Inside the Chips

There are two parts of the code, the Arduino side and the PC side.

Good News

The Arduino can work on its own🎊

Bad News

It will have limited functionality

Arduino Side

The Arduino side works in three parts:

And here's the actual code

Arduino Code
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Encoder.h>
#include <HID-Project.h>
#include <HID-Settings.h>
#include <ArduinoJson.h>
    
#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels

// Declaration for an SSD1306 display connected to I2C (SDA, SCL pins)
#define OLED_RESET     -1 // Reset pin # (or -1 if sharing Arduino reset pin)
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

#define SW_PIN 8 // Encoder Switch pin to 8

// Half of the total level the knob can adjust (-50 to +50)
#define KNOB_LEVEL_HALF 50
#define ENC_RATIO 1

#define CHAR_W 6
#define CHAR_H 8

#define MAIN_TAB_W 92
#define SIDE_TAB_W 32
#define ROUND_CORNER_R 16

#define MEDIA_TIMEOUT 3000

// 'volume_off_black_48dp', 24x24px
const unsigned char epd_bitmap_volume_off_black_48dp [] PROGMEM = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x1c, 0x03, 0x80, 0x0e, 
    0x01, 0xc0, 0x07, 0x10, 0xe0, 0x03, 0x80, 0x70, 0x01, 0xc2, 0x30, 0x1f, 0xe3, 0x38, 0x1f, 0xf1, 
    0x18, 0x1f, 0xf8, 0x18, 0x1f, 0xfc, 0x18, 0x1f, 0xfe, 0x18, 0x1f, 0xf7, 0x18, 0x00, 0xf3, 0x80, 
    0x00, 0x71, 0xc0, 0x00, 0x30, 0xe0, 0x00, 0x01, 0xf0, 0x00, 0x03, 0xb8, 0x00, 0x00, 0x10, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
// 'play_pause_flaticon', 24x24px
const unsigned char epd_bitmap_play_pause_flaticon [] PROGMEM = {
    0x00, 0x00, 0x00, 0x30, 0x00, 0x08, 0x38, 0x00, 0x18, 0x3c, 0x00, 0x30, 0x3f, 0x00, 0x60, 0x3f, 
    0x80, 0xc0, 0x3f, 0xe0, 0x80, 0x3f, 0xc1, 0x80, 0x3f, 0x83, 0x00, 0x3f, 0x06, 0x00, 0x3c, 0x0c, 
    0x00, 0x38, 0x18, 0x00, 0x20, 0x18, 0x00, 0x00, 0x30, 0x00, 0x00, 0x67, 0x9c, 0x00, 0xc7, 0x9c, 
    0x01, 0x87, 0x9c, 0x01, 0x07, 0x9c, 0x03, 0x07, 0x9c, 0x06, 0x07, 0x9c, 0x0c, 0x07, 0x9c, 0x18, 
    0x07, 0x9c, 0x10, 0x03, 0x9c, 0x00, 0x00, 0x00
};
// 'round_skip_next_black_48dp', 24x24px
const unsigned char epd_bitmap_round_skip_next_black_48dp [] PROGMEM = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0xc0, 0x03, 0x00, 0xc0, 0x03, 0xc0, 0xc0, 0x03, 0xe0, 0xc0, 0x03, 0xf0, 
    0xc0, 0x03, 0xfc, 0xc0, 0x03, 0xfc, 0xc0, 0x03, 0xf0, 0xc0, 0x03, 0xe0, 0xc0, 0x03, 0xc0, 0xc0, 
    0x03, 0x00, 0xc0, 0x00, 0x00, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
// 'round_skip_previous_black_48dp', 24x24px
const unsigned char epd_bitmap_round_skip_previous_black_48dp [] PROGMEM = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xc0, 0x03, 0x03, 0xc0, 0x03, 0x07, 0xc0, 0x03, 0x0f, 
    0xc0, 0x03, 0x3f, 0xc0, 0x03, 0x3f, 0xc0, 0x03, 0x0f, 0xc0, 0x03, 0x07, 0xc0, 0x03, 0x03, 0xc0, 
    0x03, 0x00, 0xc0, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
// 'volume_up_black_48dp', 24x24px
const unsigned char epd_bitmap_volume_up_black_48dp [] PROGMEM = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x80, 0x00, 
    0x01, 0xc0, 0x00, 0x70, 0xe0, 0x00, 0xf0, 0x70, 0x01, 0xf2, 0x30, 0x1f, 0xf3, 0x38, 0x1f, 0xf3, 
    0x18, 0x1f, 0xf3, 0x18, 0x1f, 0xf3, 0x18, 0x1f, 0xf3, 0x18, 0x1f, 0xf3, 0x38, 0x00, 0xf2, 0x30, 
    0x00, 0x70, 0x70, 0x00, 0x30, 0xe0, 0x00, 0x01, 0xc0, 0x00, 0x03, 0x80, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};

// Array of all bitmaps for convenience. (Total bytes used to store images in PROGMEM = 384)
const int epd_bitmap_allArray_LEN = 5;
const unsigned char* epd_bitmap_allArray[5] = {
    epd_bitmap_volume_off_black_48dp,
    epd_bitmap_play_pause_flaticon,
    epd_bitmap_round_skip_next_black_48dp,
    epd_bitmap_round_skip_previous_black_48dp,
    epd_bitmap_volume_up_black_48dp
};

int oldPos = 0;
int currPos = -KNOB_LEVEL_HALF;
int prevVal;
int currVal;
int knobExceed = 0;
int currExceed = 0;

unsigned long firstDownTime;
bool buttonState;
bool buttonPrevState;
bool startButton;
int counter;

unsigned long showMediaTime;
int mediaResult;
bool isMute;
// Connect to pin 2 and pin 3, only two pins with interrupt ability to ensure max performance
// // For Arduino UNO
// Encoder myEnc(2, 3);
// For Arduino Pro mini
Encoder myEnc(1, 0);

// This function composes a JSON string that includes both the raw encoder value and the processed value and send it to P5.js
void updateSerial() {
    Serial.print("[");
    Serial.print(currVal);
    Serial.print(",");
    Serial.print(mediaResult);
    Serial.println("]");
}

void updateENC(bool forceUpdate = false, bool noUpdate = false) {
    // Read from the rotary encoder
    currPos = posConv(myEnc.read());
    // Curr value is from 0 to 100
    currVal = 1 * (currPos + KNOB_LEVEL_HALF); 
    // Enter if converted position changed or Exceeding status changed
    if (currPos != oldPos || currExceed != knobExceed || forceUpdate) {
    oldPos = currPos;
    // Set currExceed same as knobExceed
    currExceed = knobExceed;
    // drawMainScreen();
    // display.display();
    if (currVal < prevVal && !noUpdate) {
        // Turn down volume
        Consumer.write(MEDIA_VOLUME_DOWN);
        updateSerial();
    }
    if (currVal > prevVal && !noUpdate) {
        // Turn up volume
        Consumer.write(MEDIA_VOLUME_UP);
        updateSerial();
    }
    prevVal = currVal;
    }
}

void setup() {
    Serial.begin(115200);
    Consumer.begin();
    // Pin 8 as the switch detect pin is set to INPUT, and when it's low it is depressed
    pinMode(SW_PIN, INPUT);
    myEnc.write(-KNOB_LEVEL_HALF * ENC_RATIO - (int)(ENC_RATIO / 2));
    if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { 
    // Serial.println(F("SSD1306 allocation failed"));
    for(;;); // Don't proceed, loop forever
    }
    // Clear the screen
    display.clearDisplay();

    display.setTextSize(2);
    display.setTextColor(WHITE);
    currVal = 1 * (currPos + KNOB_LEVEL_HALF); 
    updateENC(true);
    showMediaTime = millis();
}

void loop() {
    // If there are data from P5.js
    if (Serial.available() > 0) {
    // Read data
    String rawData = Serial.readStringUntil('\n');
    rawData.trim();                        // remove any \r \n whitespace at the end of the String
    // Serial.println(rawData.substring(0, rawData.indexOf(",")));
    int pcVolume = (rawData.substring(0, rawData.indexOf(","))).toInt();
    String rawMute = rawData.substring(rawData.indexOf(",") + 1);
    if (rawMute != "-1") {
        isMute = rawMute == "1" ? true : false;

        // Write the value to the encoder by reverting it
        // Serial.print("Ismute" + isMute);
        myEnc.write(((pcVolume) - KNOB_LEVEL_HALF) * ENC_RATIO);
        // Force update the screen with new value
        updateENC(true, true);
    }
    }
    else {
    updateENC();
    }

    int result = updateButton();
    if (millis() - showMediaTime < MEDIA_TIMEOUT) {
    // Still show media screen
    if (result >= 0) {
        //Button Pressed
        mediaResult = result;
        // Serial.println("Pressed " + String(mediaResult));
        updateMediaCommand(mediaResult);
        drawMultiScreen(mediaResult);
        updateSerial();
        showMediaTime = millis();
    }
    else {
        drawMultiScreen(mediaResult);
    }
    }
    else {
    // Not showing media screen now
    if (result >= 0) {
        //Button Pressed
        mediaResult = result;
        // Serial.println("Pressed " + String(mediaResult));
        updateMediaCommand(mediaResult);
        transitionMultiScreen(true);
        updateSerial();
        drawMultiScreen(mediaResult);
        showMediaTime = millis();
    }
    else {
        if (mediaResult >= 0) {
        transitionMultiScreen(false);
        mediaResult = -1;
        }
        drawMainScreen();
    }
    }


    delay(1);
}

int posConv(long inputPos) {
    // Serial.println(inputPos);
    int result;
    // Result is position divided by four since the knob clicks every four turns
    result = (int)(round((inputPos) / (float)ENC_RATIO));
    // Serial.println(result);
    if (result > KNOB_LEVEL_HALF) {
    // Knob value exceeds max value
    result = KNOB_LEVEL_HALF;
    // Set knob value to max to prevent further increase 
    myEnc.write(KNOB_LEVEL_HALF * ENC_RATIO + (int)(ENC_RATIO  / 2));
    knobExceed = 1;
    }
    else if (result < -KNOB_LEVEL_HALF) {
    // Knob value exceeds min value
    result = -KNOB_LEVEL_HALF;
    // Set knob value to min to prevent further decrease 
    myEnc.write(-KNOB_LEVEL_HALF * ENC_RATIO - (int)(ENC_RATIO / 2));
    knobExceed = -1;
    }
    else {
    knobExceed = 0;
    }
    return result;
}

int drawPartialRoundRect(int _x, int _y, int _w, int _h, int _r, bool isInvert, float percent) {
    int boundX = round(_x + percent * _w);
    display.fillRoundRect(_x, _y, _w, _h, _r, isInvert ? BLACK : WHITE);
    display.fillRect(boundX, _y, round((1 - percent) * _w), _h, isInvert ? WHITE : BLACK);
    display.drawRoundRect(_x, _y, _w, _h, _r, isInvert ? BLACK : WHITE);
    return boundX;
}

void drawVolumeRect(int _w, int _h) {
    // How many numbers to display "0" is 0.5, "12" is 1.0
    float halfCharNum = 1.0;
    int textSize = 2;
    int cursorX;
    int boundX = drawPartialRoundRect(0, 0, _w, _h, ROUND_CORNER_R, false, (currVal / 100.0));
    display.setTextSize(textSize);
    if (currVal < 10) {
    halfCharNum = 0.5;
    }
    else if (currVal < 100) {
    halfCharNum = 1.0;
    }
    else {
    halfCharNum = 1.5;
    }
    cursorX = round((_w / 2) - halfCharNum * CHAR_W * textSize);
    display.setCursor(cursorX, round((_h / 2) - CHAR_H));

    display.setTextColor(WHITE, BLACK);
    // If text merges with the white part, turn it into black text on white background
    if (cursorX < boundX - 1) {
    display.setTextColor(BLACK, WHITE);
    }
    display.print(currVal); 
    display.setTextColor(WHITE);
}

void drawPlayTab(int status) {
    int fullStatus = status;
    if (status == 0 && isMute) {
    fullStatus = 4;
    }
    int iconSize = 24;
    display.fillRoundRect(96, 0, 32, SCREEN_HEIGHT, ROUND_CORNER_R, WHITE);
    display.drawBitmap(round(96 + (32 - iconSize) / 2), round((SCREEN_HEIGHT - iconSize) / 2), epd_bitmap_allArray[fullStatus], iconSize, iconSize, BLACK);
}

void drawMainScreen() {
    drawVolumeRect(SCREEN_WIDTH, SCREEN_HEIGHT);
    display.display();
}

void drawMultiScreen(int status) {
    drawVolumeRect(MAIN_TAB_W, SCREEN_HEIGHT);
    drawPlayTab(status);
    display.display();
}

void transitionMultiScreen(bool direction) {
    // Clear buffer
    display.clearDisplay();
    // Play 20 frames of the animation
    int iteration = 20;
    int mainW;
    int mainH = SCREEN_HEIGHT;

    int mediaW;
    int mediaH = SCREEN_HEIGHT;

    // Draw a enlarging rounded rectangle here
    for (int i = 0; i < iteration; i++) {
    // Allow user to scroll while animation is playing
    updateENC();
    display.clearDisplay();
    // Calculate W, H, and radius of the rectangle
    if (direction) {
        mainW = round(easeInOutSine(i, 0, 20, SCREEN_WIDTH, MAIN_TAB_W));
        mediaW = round(easeInOutSine(i, 0, 20, 0, SIDE_TAB_W));
    }
    else {
        mainW = round(easeInOutSine(i, 0, 20, MAIN_TAB_W, SCREEN_WIDTH));
        mediaW = round(easeInOutSine(i, 0, 20, SIDE_TAB_W, 0));
    }

    // Draw the rectangle
    drawVolumeRect(mainW, mainH);

    display.fillRoundRect(SCREEN_WIDTH - mediaW, 0, mediaW, mediaH, ROUND_CORNER_R, WHITE);

    delay(5);
    display.display();
    delay(10);
    }
}

void updateMediaCommand(int mediaStatus) {
    if (mediaStatus == 0) {
    // Mute
    Consumer.write(MEDIA_VOLUME_MUTE);
    }
    else if (mediaStatus == 1) {
    Consumer.write(MEDIA_PLAY_PAUSE);
    }
    else if (mediaStatus == 2) {
    Consumer.write(MEDIA_NEXT);
    }
    else if (mediaStatus == 3) {
    Consumer.write(MEDIA_PREVIOUS);
    }
    
}

//Reads and returns how many clicks are there for the button
int updateButton() {
    if (millis() - firstDownTime > 500 && startButton) {
        // Serial.println("Pressed " + String(counter));
        int result = counter;
        counter = 0;
        startButton = false;
        // Constrain the clicks from none to 3
        return constrain(result, -1, 3);
    }

    buttonState = digitalRead(SW_PIN);
    
    if (buttonState != buttonPrevState) {
        delay(10);
        if(digitalRead(SW_PIN) == buttonState) {
            if(buttonState == LOW) {
                // Shifted LOW
                startButton = true;
                firstDownTime = millis();
            }
            else {
                // Shifted HIGH
                if (startButton) {
                    counter++;
                }
            }
        }
    }
    buttonPrevState = buttonState;
    return -1;
}

// This function is similar to the built-in map() function, but with a eased curve at the start and the end of the range.
// Similar to the ease-in-out function in CSS
float easeInOutSine(int _x, int inputLow, int inputHigh, int lowerBound, int higherBound) {
    int inputRange = inputHigh - inputLow - 1;
    float convX = ((float)_x - (float)inputLow) / (float)inputRange;
    // Serial.print(String(convX) + "; ");
    int range = higherBound - lowerBound;
    float result = (-(cos(PI * convX) - 1.0) / 2.0) * range + lowerBound;
    // Serial.println(result);
    return result;
}

Python Code

The Python code is simple, I utilized the PYCAW package to read the volume and mute status of a Windows machine. Pyserial is also used to communicate with the arduino through serial port. The code reads volume and mute information from PC and also reads arduino's stored volume and mute status. It then determines if arduino's volume and mute status need update, if it needs, the code sends the updated status through serial.

Working without Python

As mentioned before, the project can work without the Python script. But without the ability to sync volume with the PC, it cannot show the correct volume and control the PC's volume when Arduino reaches the limit. For example, if the PC's volume is at 35, but Arduino shows 0, then Arduino cannot make the PC's volume lower since on the Arduino's side, the volume is already 0, the lowest one.

Wrapping by EXE

To make the code more convenient to use, I wrapped my code into a .exe file using auto-py-to-exe.

import serial
import time
import json
from ctypes import cast, POINTER
from comtypes import CLSCTX_ALL
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume

DEFAULT_PORT = "COM8"

def port_prompt():
    target_port = input("Please enter a port: ")
    if target_port == '' or target_port == None:
        return DEFAULT_PORT
    return target_port

arduino_port = port_prompt()
print("Starting Serial at " + arduino_port + "...")
arduino = serial.Serial(port = arduino_port)
time.sleep(0.5)

print("Starting audio services...")
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(
    IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
volume = cast(interface, POINTER(IAudioEndpointVolume))
    
def read_arduino():
    line = arduino.readline()   # read a byte
    if line:
        string = line.decode()  # convert the byte string to a unicode string

        data = json.loads(string)

        time.sleep(0.01)
        return data[0], data[1]
    return -1, -1
    
def send_arduino(pc_volume, is_mute):
    send_str = ""
    send_str += str(pc_volume) + "," + str(is_mute) + "\n"
    print("Sending to serial: " + send_str)
    arduino.write(send_str.encode("utf-8"))

def main():
    arduino_vol = -1
    arduino_mute = -1
    while True:
        time.sleep(0.001)

        curr_vol = round(volume.GetMasterVolumeLevelScalar() * 100)
        curr_mute = volume.GetMute()
        # print("Curr_vol" + str(curr_vol))
        while arduino.in_waiting:
            send_arduino(curr_vol, -1) 

            arduino_vol, arduino_mute = read_arduino()

            curr_mute = volume.GetMute()

        if arduino_vol != curr_vol or arduino_mute != curr_mute:
            send_arduino(curr_vol, 1 - curr_mute)
            arduino_vol = curr_vol
            arduino_mute = curr_mute

main()

Actual Operation

Coming alive

The above video shows how the device operates, turning the knob can change the volume of the music, single click of button can play / pause audio, double click to skip track, triple click to reverse track, and long press to mute / unmute.