
note taker by malvika.jain on instagram
a hold-to-talk notebook that lives on an esp32. press the button, speak your thought, let go. recordings sync over bluetooth (or usb) into a tiny web app that transcribes them for you.
description
a hold-to-talk notebook that lives on an esp32. press the button, speak your thought, let go. recordings sync over bluetooth (or usb) into a tiny web app that transcribes them for you.
materials
steps
gather your parts
- check off the materials list: esp32-s3 sense, breadboard + jumper wires, push button, led + 220Ω resistor, and usb-c cable.
- you also need a computer with python 3 and the arduino ide.
seat the board
- press the esp32-s3 sense into the breadboard. leave room beside it for the button and led. the mic is onboard — no extra wiring.
wire the button
- use jumper wires: one side of the button to d02 (gpio 2) on the esp32, the other side to gnd. hold = record.
wire the led
- d03 (gpio 3) → resistor → long led leg. short led leg → gnd. the led lights while recording.
flash the firmware
- download notebook_recorder.ino, open it in arduino ide, install the espressif esp32 board package, then plug in the usb-c cable.
- pick your esp32-s3 board and port, upload, and set serial monitor to 115200. you should see “ready — hold d02 to record.”
run the companion app
- download recorder_ui.py, then: pip install flask pyserial openai-whisper bleak && python recorder_ui.py
- a browser should open at http://localhost:5000.
record a note
- hold the button, speak, let go. the led stays on while you hold. the clip syncs over bluetooth, transcribes, and saves to ~/desktop/recordings.
- if bluetooth fails, plug in usb and click “usb sync.”
schematic
scripts
01 · notebook_recorder.ino
download// Hold D02 (GPIO 2) to record. BLE auto-sync, or USB via web app.
#include <ESP_I2S.h>
#include <LittleFS.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
I2SClass i2s;
#define PDM_CLK 42
#define PDM_DATA 41
#define BUTTON 2 // D02
#define RECORD_LED 3 // D03 → resistor → LED → GND
#define SAMPLE_RATE 16000
#define SERVICE_UUID "F0000001-0000-4000-8000-000000000001"
#define META_UUID "F0000002-0000-4000-8000-000000000002"
#define AUDIO_UUID "F0000003-0000-4000-8000-000000000003"
#define CTRL_UUID "F0000004-0000-4000-8000-000000000004"
#define BLE_CHUNK 200
#define BLE_DELAY 30
int nextIndex = 0;
bool recording = false;
bool bleConnected = false;
bool bleAppReady = false;
bool bleSendPending = false;
File recFile;
BLECharacteristic* metaChar = nullptr;
BLECharacteristic* audioChar = nullptr;
BLECharacteristic* ctrlChar = nullptr;
class BleCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer* server) {
bleConnected = true;
bleAppReady = false;
bleSendPending = false;
BLEDevice::setMTU(517);
}
void onDisconnect(BLEServer* server) {
bleConnected = false;
bleAppReady = false;
bleSendPending = false;
BLEDevice::startAdvertising();
}
};
class CtrlCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic* ch) {
String value = ch->getValue();
if (value.length() > 0 && (uint8_t)value[0] == 0x01) {
bleAppReady = true;
bleSendPending = true;
}
}
};
void loadCounter() {
File f = LittleFS.open("/counter.txt", "r");
if (f) {
nextIndex = f.parseInt();
f.close();
}
}
void saveCounter() {
File f = LittleFS.open("/counter.txt", "w");
if (f) {
f.print(nextIndex);
f.close();
}
}
void compactIfEmpty() {
for (int i = 0; i < nextIndex; i++) {
if (LittleFS.exists("/rec_" + String(i) + ".pcm")) return;
}
nextIndex = 0;
saveCounter();
}
bool sendBleFile(int i) {
if (!bleConnected || !metaChar || !audioChar || !ctrlChar) return false;
String path = "/rec_" + String(i) + ".pcm";
if (!LittleFS.exists(path)) return false;
File f = LittleFS.open(path, "r");
if (!f) return false;
uint32_t size = f.size();
uint8_t sz[4];
memcpy(sz, &size, 4);
metaChar->setValue(sz, 4);
metaChar->notify();
delay(50);
uint8_t buf[BLE_CHUNK];
while (f.available()) {
int n = f.read(buf, sizeof(buf));
if (n <= 0) break;
audioChar->setValue(buf, n);
audioChar->notify();
delay(BLE_DELAY);
}
f.close();
delay(100);
uint8_t end = 0xFF;
ctrlChar->setValue(&end, 1);
ctrlChar->notify();
delay(50);
LittleFS.remove(path);
compactIfEmpty();
return true;
}
void sendAllBle() {
for (int i = 0; i < nextIndex; i++) {
if (!LittleFS.exists("/rec_" + String(i) + ".pcm")) continue;
if (!sendBleFile(i)) break;
}
}
void setupBle() {
BLEDevice::init("Notebook Recorder");
BLEDevice::setMTU(517);
BLEServer* server = BLEDevice::createServer();
server->setCallbacks(new BleCallbacks());
BLEService* service = server->createService(SERVICE_UUID);
metaChar = service->createCharacteristic(META_UUID, BLECharacteristic::PROPERTY_NOTIFY);
metaChar->addDescriptor(new BLE2902());
audioChar = service->createCharacteristic(AUDIO_UUID, BLECharacteristic::PROPERTY_NOTIFY);
audioChar->addDescriptor(new BLE2902());
ctrlChar = service->createCharacteristic(
CTRL_UUID,
BLECharacteristic::PROPERTY_NOTIFY | BLECharacteristic::PROPERTY_WRITE
);
ctrlChar->addDescriptor(new BLE2902());
ctrlChar->setCallbacks(new CtrlCallbacks());
service->start();
BLEAdvertising* adv = BLEDevice::getAdvertising();
adv->addServiceUUID(SERVICE_UUID);
adv->setScanResponse(true);
adv->start();
}
void sendFileUsb(int i) {
String path = "/rec_" + String(i) + ".pcm";
if (!LittleFS.exists(path)) return;
File f = LittleFS.open(path, "r");
if (!f) return;
Serial.println("FILE:" + String(i) + ":" + String(f.size()));
uint8_t buf[512];
while (f.available()) {
int n = f.read(buf, sizeof(buf));
if (n > 0) Serial.write(buf, n);
}
f.close();
Serial.println("EOF");
}
void syncFiles() {
int count = 0;
for (int i = 0; i < nextIndex; i++) {
if (LittleFS.exists("/rec_" + String(i) + ".pcm")) count++;
}
Serial.println("COUNT:" + String(count));
for (int i = 0; i < nextIndex; i++) sendFileUsb(i);
Serial.println("SYNCDONE");
}
void clearFiles() {
for (int i = 0; i < nextIndex; i++) {
LittleFS.remove("/rec_" + String(i) + ".pcm");
}
nextIndex = 0;
saveCounter();
Serial.println("CLEARED");
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON, INPUT_PULLUP);
pinMode(RECORD_LED, OUTPUT);
digitalWrite(RECORD_LED, LOW); // off when idle
LittleFS.begin(true);
loadCounter();
i2s.setPinsPdmRx(PDM_CLK, PDM_DATA);
i2s.begin(I2S_MODE_PDM_RX, SAMPLE_RATE, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO);
setupBle();
Serial.println("READY — hold D02 to record");
Serial.println("Button GPIO " + String(BUTTON) + " reads " + String(digitalRead(BUTTON)) + " (1=open, 0=pressed)");
}
void loop() {
pinMode(BUTTON, INPUT_PULLUP);
if (Serial.available()) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd == "SYNC") syncFiles();
if (cmd == "CLEAR") clearFiles();
}
if (bleConnected && bleSendPending && !recording) {
bleSendPending = false;
sendAllBle();
}
int raw = digitalRead(BUTTON);
bool held = raw == LOW;
static bool lastHeld = false;
if (held != lastHeld) {
lastHeld = held;
Serial.print("D02: ");
Serial.print(held ? "PRESSED" : "RELEASED");
Serial.print(" raw=");
Serial.println(raw);
}
if (held && !recording) {
recFile = LittleFS.open("/rec_" + String(nextIndex) + ".pcm", "w");
if (recFile) {
recording = true;
Serial.println("REC_START " + String(nextIndex));
} else {
Serial.println("REC_START failed — could not open file");
}
}
if (!held && recording) {
recFile.close();
int saved = nextIndex;
nextIndex++;
saveCounter();
recording = false;
Serial.println("REC_DONE");
if (bleConnected && bleAppReady) sendBleFile(saved);
}
if (recording) {
uint8_t buf[512];
int n = i2s.readBytes((char*)buf, sizeof(buf));
if (n > 0) recFile.write(buf, n);
}
digitalWrite(RECORD_LED, recording ? HIGH : LOW);
}
02 · recorder_ui.py
downloadfrom flask import Flask, jsonify, send_file
import os, wave, threading, webbrowser, time, asyncio
import serial
import serial.tools.list_ports
import whisper
try:
from bleak import BleakClient, BleakScanner
BLE_OK = True
except ImportError:
BLE_OK = False
app = Flask(__name__)
SAVE_FOLDER = os.path.expanduser("~/Desktop/recordings")
SAMPLE_RATE = 16000
BAUD_RATE = 115200
SERVICE_UUID = "F0000001-0000-4000-8000-000000000001"
META_UUID = "F0000002-0000-4000-8000-000000000002"
AUDIO_UUID = "F0000003-0000-4000-8000-000000000003"
CTRL_UUID = "F0000004-0000-4000-8000-000000000004"
DEVICE_NAME = "Notebook Recorder"
BLE_READY = b"\x01"
os.makedirs(SAVE_FOLDER, exist_ok=True)
sync_status = {"running": False, "message": "Ready"}
ble_status = {
"connected": False,
"message": "Bluetooth ready" if BLE_OK else "Install bleak: pip install bleak",
"list_updated": False,
}
status_lock = threading.Lock()
whisper_model = None
HTML = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>recordings</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Courier New", Courier, monospace;
font-size: 14px; line-height: 1.5;
background: #fff; color: #000; min-height: 100vh;
}
header {
border-bottom: 2px solid #000;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
header h1, button, .rec-name, .transcription, #empty, #status-bar, #ble-bar {
font-size: 14px;
font-family: inherit;
}
button {
background: #fff;
color: #000;
border: 2px solid #000;
padding: 6px 12px;
cursor: pointer;
}
button:hover { background: #000; color: #fff; }
button:disabled {
background: #fff;
color: #999;
border-color: #999;
cursor: not-allowed;
}
button:disabled:hover { background: #fff; color: #999; }
#status-bar, #ble-bar {
border-bottom: 2px solid #000;
padding: 10px 20px;
}
#status-bar { display: none; }
#ble-bar.connected { background: #e8ffe8; }
#ble-bar.disconnected { background: #fff8e8; }
main { padding: 0; }
#empty { padding: 20px; margin-top: 20px; color: #999; }
.list { display: flex; flex-direction: column; }
.rec {
border-top: 2px solid #000;
padding: 16px 20px;
width: 100%;
}
.rec-name { margin-bottom: 8px; }
.transcription {
margin-bottom: 12px;
white-space: pre-wrap;
}
.rec-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.icon-btn {
width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.icon-btn svg {
width: 16px;
height: 16px;
display: block;
}
.btn-play .icon-stop { display: none; }
.btn-play.playing .icon-play { display: none; }
.btn-play.playing .icon-stop { display: block; }
.btn-copy .icon-check { display: none; }
.btn-copy.copied .icon-copy { display: none; }
.btn-copy.copied .icon-check { display: block; }
</style>
</head>
<body>
<header>
<h1>recordings</h1>
<button id="sync-btn" onclick="sync()" title="USB fallback">usb sync</button>
</header>
<div id="ble-bar" class="disconnected">bluetooth...</div>
<div id="status-bar"></div>
<main>
<div id="empty" style="display:none">no recordings — they'll sync over bluetooth automatically</div>
<div class="list" id="grid"></div>
</main>
<script>
const player = new Audio();
let playingBtn = null;
player.onended = () => {
if (playingBtn) playingBtn.classList.remove('playing');
playingBtn = null;
};
async function loadList() {
const recs = await (await fetch('/recordings')).json();
const grid = document.getElementById('grid');
const empty = document.getElementById('empty');
grid.innerHTML = '';
if (!recs.length) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
recs.reverse().forEach(rec => {
const el = document.createElement('div');
el.className = 'rec';
el.innerHTML = `
<div class="rec-name">${rec.name}</div>
<div class="transcription">${rec.text}</div>
<div class="rec-actions">
<button type="button" class="icon-btn btn-play" aria-label="Play" onclick="togglePlay('${rec.name}', this)">
<svg class="icon-play" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<svg class="icon-stop" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12"/></svg>
</button>
<button type="button" class="icon-btn btn-copy" aria-label="Copy" onclick="copyText(this)">
<svg class="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="13" height="13"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
<svg class="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
</button>
<button type="button" class="icon-btn btn-delete" aria-label="Delete" onclick="deleteRec('${rec.name}', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14"/></svg>
</button>
</div>`;
grid.appendChild(el);
});
}
function togglePlay(name, btn) {
const src = '/audio/' + name;
if (playingBtn && playingBtn !== btn) playingBtn.classList.remove('playing');
if (player.src.endsWith(src) && !player.paused) {
player.pause();
btn.classList.remove('playing');
playingBtn = null;
return;
}
player.src = src;
player.play();
btn.classList.add('playing');
playingBtn = btn;
}
function copyText(btn) {
const text = btn.closest('.rec').querySelector('.transcription').innerText;
navigator.clipboard.writeText(text).then(() => {
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 1500);
});
}
async function deleteRec(name, btn) {
if (!confirm('Delete ' + name + '?')) return;
if (player.src.endsWith('/audio/' + name)) {
player.pause();
player.removeAttribute('src');
if (playingBtn) playingBtn.classList.remove('playing');
playingBtn = null;
}
await fetch('/delete/' + name, { method: 'DELETE' });
btn.closest('.rec').remove();
if (!document.querySelector('.rec')) document.getElementById('empty').style.display = 'block';
}
async function sync() {
const btn = document.getElementById('sync-btn');
btn.disabled = true;
setStatus('Syncing...');
await fetch('/sync', { method: 'POST' });
const timer = setInterval(async () => {
const s = await (await fetch('/sync/status')).json();
setStatus(s.message);
if (!s.running) {
clearInterval(timer);
btn.disabled = false;
loadList();
}
}, 500);
}
function setStatus(msg) {
const bar = document.getElementById('status-bar');
bar.style.display = 'block';
bar.textContent = msg;
}
async function pollBle() {
try {
const s = await (await fetch('/ble/status')).json();
const bar = document.getElementById('ble-bar');
bar.textContent = s.message;
bar.className = s.connected ? 'connected' : 'disconnected';
if (s.list_updated) {
await loadList();
await fetch('/ble/clear-updated', { method: 'POST' });
}
} catch (e) {}
}
loadList();
setInterval(pollBle, 1500);
pollBle();
</script>
</body>
</html>
"""
@app.route("/")
def index():
return HTML
@app.route("/recordings")
def list_recordings():
recs = []
for wav in sorted(f for f in os.listdir(SAVE_FOLDER) if f.endswith(".wav")):
name = wav[:-4]
txt = os.path.join(SAVE_FOLDER, name + ".txt")
text = open(txt).read() if os.path.exists(txt) else ""
recs.append({"name": name, "text": text})
return jsonify(recs)
@app.route("/audio/<name>")
def get_audio(name):
return send_file(os.path.join(SAVE_FOLDER, name + ".wav"), mimetype="audio/wav")
@app.route("/delete/<name>", methods=["DELETE"])
def delete_recording(name):
for ext in (".wav", ".txt"):
path = os.path.join(SAVE_FOLDER, name + ext)
if os.path.exists(path):
os.remove(path)
return jsonify({"ok": True})
@app.route("/sync", methods=["POST"])
def sync():
if sync_status["running"]:
return jsonify({"error": "busy"})
threading.Thread(target=do_sync, daemon=True).start()
return jsonify({"ok": True})
@app.route("/sync/status")
def get_sync_status():
return jsonify(sync_status)
@app.route("/ble/status")
def get_ble_status():
with status_lock:
return jsonify(dict(ble_status))
@app.route("/ble/clear-updated", methods=["POST"])
def clear_ble_updated():
with status_lock:
ble_status["list_updated"] = False
return jsonify({"ok": True})
def find_port():
for p in serial.tools.list_ports.comports():
dev = p.device
if dev.startswith("/dev/tty."):
cu = "/dev/cu." + dev[9:]
if os.path.exists(cu):
dev = cu
if "usbmodem" in dev or "SLAB" in dev or "CH340" in dev:
return dev
return None
def next_index():
nums = []
for f in os.listdir(SAVE_FOLDER):
if f.startswith("rec_") and f.endswith(".wav"):
try:
nums.append(int(f[4:-4]))
except ValueError:
pass
return max(nums, default=-1) + 1
def save_wav(pcm, index):
path = os.path.join(SAVE_FOLDER, f"rec_{index}.wav")
with wave.open(path, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(pcm)
return path
def get_model():
global whisper_model
if whisper_model is None:
print("Loading Whisper...")
whisper_model = whisper.load_model("base")
return whisper_model
def transcribe(wav_path, name):
text = get_model().transcribe(wav_path, language="en")["text"].strip()
with open(os.path.join(SAVE_FOLDER, name + ".txt"), "w") as f:
f.write(text)
return text
def save_ble_recording(pcm):
idx = next_index()
name = f"rec_{idx}"
wav = save_wav(pcm, idx)
set_ble(message="Transcribing...", list_updated=True)
def work():
try:
transcribe(wav, name)
except Exception as e:
print(f"BLE transcribe failed: {e}")
set_ble(message="Connected — recordings sync automatically", list_updated=True)
threading.Thread(target=work, daemon=True).start()
# ── Bluetooth ─────────────────────────────────────────────
class BleRx:
def __init__(self):
self.lock = threading.Lock()
self.buf = bytearray()
self.active = False
ble_rx = BleRx()
def set_ble(connected=None, message=None, list_updated=None):
with status_lock:
if connected is not None:
ble_status["connected"] = connected
if message is not None:
ble_status["message"] = message
if list_updated is not None:
ble_status["list_updated"] = list_updated
def finish_ble_recording():
with ble_rx.lock:
if not ble_rx.active:
return
pcm = bytes(ble_rx.buf)
ble_rx.active = False
ble_rx.buf = bytearray()
if not pcm:
return
try:
save_ble_recording(pcm)
except Exception as e:
print(f"BLE save failed: {e}")
set_ble(message=f"Error saving recording: {e}")
def schedule_finish():
threading.Timer(0.3, finish_ble_recording).start()
def on_meta(sender, data):
if len(data) < 4:
return
with ble_rx.lock:
ble_rx.buf = bytearray()
ble_rx.active = True
set_ble(message="Receiving recording...")
def on_audio(sender, data):
with ble_rx.lock:
if ble_rx.active:
ble_rx.buf.extend(data)
def on_ctrl(sender, data):
if data and data[0] == 0xFF:
schedule_finish()
def is_recorder(device, adv):
names = [device.name or "", getattr(adv, "local_name", None) or ""]
for name in names:
lower = name.lower()
if "notebook" in lower or lower == DEVICE_NAME.lower():
return True
uuids = [u.lower() for u in (getattr(adv, "service_uuids", None) or [])]
return SERVICE_UUID.lower() in uuids
async def ble_loop():
while True:
set_ble(connected=False, message="Scanning for Notebook Recorder...")
device = None
try:
found = await BleakScanner.discover(timeout=10.0, return_adv=True)
for dev, adv in found.values():
if is_recorder(dev, adv):
device = dev
break
except Exception as e:
set_ble(message=f"Bluetooth error: {e}")
await asyncio.sleep(5)
continue
if not device:
await asyncio.sleep(2)
continue
try:
async with BleakClient(device, timeout=20.0) as client:
await client.start_notify(META_UUID, on_meta)
await client.start_notify(AUDIO_UUID, on_audio)
await client.start_notify(CTRL_UUID, on_ctrl)
await client.write_gatt_char(CTRL_UUID, BLE_READY, response=True)
set_ble(connected=True, message="Connected — recordings sync automatically")
while client.is_connected:
await asyncio.sleep(1)
except Exception as e:
print(f"BLE disconnected: {e}")
set_ble(connected=False, message="Reconnecting...")
await asyncio.sleep(3)
def start_ble():
if not BLE_OK:
return
def run():
asyncio.run(ble_loop())
threading.Thread(target=run, daemon=True).start()
# ── USB sync ──────────────────────────────────────────────
def read_line(ser):
return ser.readline().decode(errors="replace").strip()
def read_bytes(ser, n):
data = b""
while len(data) < n:
chunk = ser.read(n - len(data))
if not chunk:
raise serial.SerialException("Connection lost during download")
data += chunk
return data
def ask_count(ser):
for _ in range(5):
ser.reset_input_buffer()
ser.write(b"SYNC\n")
ser.flush()
deadline = time.time() + 10
while time.time() < deadline:
line = read_line(ser)
if line.startswith("COUNT:"):
return int(line.split(":")[1])
return None
def set_status(running, message):
global sync_status
sync_status = {"running": running, "message": message}
def do_sync():
ser = None
set_status(True, "Connecting...")
try:
port = find_port()
if not port:
set_status(False, "Board not found — plug it in and try again")
return
ser = serial.Serial(port, BAUD_RATE, timeout=30)
time.sleep(2)
ser.reset_input_buffer()
set_status(True, "Requesting recordings...")
count = ask_count(ser)
if count is None:
set_status(False, "No response — close Serial Monitor, press RESET, try again")
return
if count == 0:
read_line(ser) # SYNCDONE
set_status(False, "No recordings on board")
return
idx = next_index()
synced = 0
for n in range(count):
header = read_line(ser)
if not header.startswith("FILE:"):
break
size = int(header.split(":")[2])
name = f"rec_{idx}"
set_status(True, f"Downloading {name} ({n + 1}/{count})...")
pcm = read_bytes(ser, size)
read_line(ser) # EOF
wav = save_wav(pcm, idx)
set_status(True, f"Transcribing {name} ({n + 1}/{count})...")
transcribe(wav, name)
idx += 1
synced += 1
read_line(ser) # SYNCDONE
ser.write(b"CLEAR\n")
read_line(ser) # CLEARED
set_status(False, f"Done! {synced} recording(s) synced.")
except serial.SerialException as e:
busy = "busy" in str(e).lower() or getattr(e, "errno", None) == 16
msg = "Port busy — close Arduino Serial Monitor and try again" if busy else str(e)
set_status(False, msg)
except Exception as e:
set_status(False, f"Error: {e}")
finally:
if ser and ser.is_open:
ser.close()
if __name__ == "__main__":
start_ble()
webbrowser.open("http://localhost:5000")
app.run(port=5000)
comments
no comments yet — be the first