<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://prototipado.github.io/en/feed.xml" rel="self" type="application/atom+xml" /><link href="https://prototipado.github.io/en/" rel="alternate" type="text/html" /><updated>2026-10-14T19:26:39+00:00</updated><id>https://prototipado.github.io/feed.xml</id><title type="html">Laboratorio de Prototipado Electrónico y 3D</title><subtitle>Diseño y Desarrollo de Sistemas Embebidos orientados a la implementación de prototipos para la industria de tecnología médica y agropecuaria.</subtitle><author><name>Laboratorio de Prototipado</name><email>juan.reta@uner.edu.ar</email></author><entry xml:lang="en"><title type="html">Reverse Engineering Specialized’s ANGi Sensor with AI Tools: A Case Study</title><link href="https://prototipado.github.io/en/developments/specialized-angi-reverse-engineering/" rel="alternate" type="text/html" title="Reverse Engineering Specialized’s ANGi Sensor with AI Tools: A Case Study" /><published>2026-10-01T00:00:00+00:00</published><updated>2026-10-01T00:00:00+00:00</updated><id>https://prototipado.github.io/developments/angi_ing_inv-en</id><content type="html" xml:base="https://prototipado.github.io/developments/specialized-angi-reverse-engineering/"><![CDATA[<p>Cycling is one of my favorite activities outside work. Near Oro Verde, there are roads leading to small towns and villages, with little traffic and plenty of people who use them for riding. Helmets are mandatory, and when I bought mine in early 2024, <a href="https://www.specialized.com">Specialized</a> offered the <a href="https://www.dcrainmaker.com/2018/11/specialized-helmet-sensor.html">ANGi</a> sensor: a crash detector that connects to a phone and, after an impact or sudden movement, sends text messages with an alert and location to the contacts you configured.</p>

<p>This year, Specialized discontinued the app that supported it and moved to a new one that is incompatible. The servers also stopped responding: it is no longer possible to sign in, reset a password, or create an account, so I can no longer use the old app. I was left with a useful device I could not use, and an uncomfortable question: did I really own it?</p>

<h3 id="document-structure">Document Structure</h3>

<p>This case study documents the complete reverse engineering process of the ANGi sensor and the development of an open, independent alternative (<em>OpenAngi</em>) using AI tools. The work is organized into 5 main parts:</p>

<ul>
  <li><strong><a href="#part-1-claude-and-ble-exploration">Part 1: Claude and BLE Exploration</a></strong> — Initial hardware exploration, BLE service enumeration, and static decompilation of the original APK.</li>
  <li><strong><a href="#part-2-opencode-and-android-app-development">Part 2: OpenCode and Android App Development</a></strong> — Building the <em>AngiTester</em> test bench, developing the final independent app <em>OpenAngi</em> (100% server-independent), and code comparison.</li>
  <li><strong><a href="#part-3-hardware-and-firmware--comparative-analysis-antigravity-vs-opencode">Part 3: Hardware and Firmware – Comparative Analysis</a></strong> — Extracting nRF52832 firmware, Ghidra analysis, finding the production UART console, and electrical power characterization.</li>
  <li><strong><a href="#part-4-dfu-firmware-update">Part 4: DFU Firmware Update</a></strong> — Locating the official firmware inside the APK, updating via Bluetooth (DFU), and integrating the process into the app.</li>
  <li><strong><a href="#part-5-assessment-of-ai-tool-usage">Part 5: Assessment of AI Tool Usage</a></strong> — Comparing strengths and limitations of each model, project scale/costs, and legal considerations.</li>
</ul>

<p>A note up front: all the tools used are available for free, which is why several different ones appear throughout the article. In some cases, we were trying different tools; in others, we ran into model limitations (for example, many models in OpenCode do not support images) or reached the available token limits.</p>

<hr />

<h2 id="part-1-claude-and-ble-exploration">Part 1: Claude and BLE Exploration</h2>

<p>We carried out the first phase entirely in Claude (Sonnet 5) through the claude.ai web interface. One continuous session, one model, no interruptions.</p>

<p>Our first question was whether the project we wanted to tackle was feasible. Claude searched the web and confirmed that ANGi runs on a Nordic Semiconductor <a href="https://www.nordicsemi.com/Products/nRF52832">nRF52832</a> (Cortex-M4F, 512 KB flash)—an extremely well-documented BLE chip with a public SDK, identified from the device’s <a href="https://fccid.io/OXXANGI001">FCC filing</a>.</p>

<p>Since the official app was effectively dead (no login, no password recovery, no new account creation), we ruled out capturing authenticated traffic and reorganized the plan around two parallel tracks:</p>
<ol>
  <li><strong>Direct hardware exploration</strong> with nRF Connect for Mobile (Nordic’s free tool), enumerating BLE services and characteristics without going through Specialized’s app.</li>
  <li><strong>Static APK decompilation</strong> of the app downloaded from a mirror (apkmirror.com, since Google Play would not let us download it either) using <a href="https://github.com/skylot/jadx">jadx</a>—the decompiled code does not depend on the backend being online.</li>
</ol>

<p>We connected to the sensor using nRF Connect and shared the first screenshots for analysis. That gave us an initial map of its services: the custom service <code class="language-plaintext highlighter-rouge">afd0ffa0…afd0ffa8</code>, Battery Service, Device Information, and Nordic’s “Buttonless” DFU service.</p>

<h3 id="a-incremental-development-of-exploration-scripts">a. Incremental Development of Exploration Scripts</h3>

<p>With Claude, we incrementally built a series of Python scripts using <a href="https://bleak.readthedocs.io/en/latest/">bleak</a> (a cross-platform BLE client). Each script answered a specific question left open by the previous one:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">angi_sniffer.py</code>: connects to the sensor, subscribes to NOTIFY characteristics, and logs all raw traffic with timestamps to the screen and a CSV file.</li>
  <li><code class="language-plaintext highlighter-rouge">angi_command_sweep.py</code>: automatically sweeps all 256 possible values (<code class="language-plaintext highlighter-rouge">0x00–0xFF</code>) on WRITE characteristics, comparing the STATUS register before and after each write to detect reactions.</li>
  <li><code class="language-plaintext highlighter-rouge">angi_heartbeat_test.py</code>: listens passively without writing anything, specifically to rule out the hypothesis that certain observed packets were command responses. The result: they were a periodic heartbeat (an uptime counter increasing ~1/second).</li>
</ul>

<p>The workflow was always the same: run the script against the real sensor → paste the log or traceback into Claude → let it adjust the code → repeat. Simple, but effective.</p>

<p>We made progress this way, but the process was relatively slow. The big leap came with decompilation. Claude put together a specific, detailed prompt for <a href="https://opencode.ai/">OpenCode</a> (another AI tool, with direct filesystem access), with step-by-step instructions: decompile with jadx, perform a targeted search (grep) for UUIDs and constants already identified over BLE, identify classes and methods for each UUID, and generate a protocol document.</p>

<p>The result was a complete protocol map extracted from the decompiled Java classes. Using that information, we built the final <code class="language-plaintext highlighter-rouge">angi_client.py</code>: a complete interactive BLE client implementing the sensor’s real extended-command pattern, parsers for the three response packet formats (ASCII, 15-byte IMU report, and 20-byte impact report), named commands, a raw <code class="language-plaintext highlighter-rouge">&lt;hex&gt;</code> mode for free-form experimentation, and an autotest that sweeps the 17 known opcodes × 5 parameter variants with automatic reconnection.</p>

<h3 id="b-identifying-the-hardware-and-on-board-sensors">b. Identifying the Hardware and On-Board Sensors</h3>

<p>Another line of investigation focused on the hardware itself: we opened the sensor and took pictures of the board.</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/top_angi.JPG" class="image-popup" title="Top view of the ANGi sensor PCB, showing the nRF52832 SoC in a BGA package, sensors, and the CR2032 coin-cell holder. The PCB is glued to the enclosure; removing it may have flexed it enough to break some traces. A jumper is visible bridging two VCC points, which was needed to get it working again.
"><img src="/assets/images/sensor_angi/top_angi.JPG" alt="Top view of the ANGi sensor board" /></a><figcaption>
      Top view of the ANGi sensor PCB, showing the nRF52832 SoC in a BGA package, sensors, and the CR2032 coin-cell holder. The PCB is glued to the enclosure; removing it may have flexed it enough to break some traces. A jumper is visible bridging two VCC points, which was needed to get it working again.

    </figcaption></figure>

<figure class="align-center"><a href="/assets/images/sensor_angi/bottom_angi.JPG" class="image-popup" title="Bottom view of the PCB showing the test points.
"><img src="/assets/images/sensor_angi/bottom_angi.JPG" alt="Bottom view of the ANGi sensor board" /></a><figcaption>
      Bottom view of the PCB showing the test points.

    </figcaption></figure>

<p>Claude identified the chips from their markings and cross-checked them against public manufacturer documentation:</p>

<ul>
  <li><strong>ST LIS3DH accelerometer</strong>: identified by the marking “C3H,” confirmed against a post by an STMicroelectronics engineer on the company’s forum.</li>
  <li><strong>ST L3GD20H gyroscope</strong>: identified by the marking “3GDH,” verified against the same source.</li>
  <li><strong>nRF52832 BLE SoC</strong>: in a BGA package beneath a protective layer of silicone.</li>
</ul>

<figure class="align-center"><a href="/assets/images/sensor_angi/foto_accel_gyro.JPG" class="image-popup" title="Close-up of the STMicroelectronics sensors identified on the board: LIS3DH accelerometer (‘C3H’) and L3GD20H gyroscope (‘3GDH’).
"><img src="/assets/images/sensor_angi/foto_accel_gyro.JPG" alt="Close-up of the sensors on the board" /></a><figcaption>
      Close-up of the STMicroelectronics sensors identified on the board: LIS3DH accelerometer (‘C3H’) and L3GD20H gyroscope (‘3GDH’).

    </figcaption></figure>

<h3 id="c-claude-session-numbers">c. Claude Session Numbers</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Metric</th>
      <th style="text-align: left">Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Model</strong></td>
      <td style="text-align: left">Claude Sonnet 5 (one model for the entire session)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Interface</strong></td>
      <td style="text-align: left">claude.ai (web chat)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Conversation turns</strong></td>
      <td style="text-align: left">26</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Tool calls</strong></td>
      <td style="text-align: left">~54 (manual count)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Images analyzed</strong></td>
      <td style="text-align: left">11 (nRF Connect screenshots, PCB photos, logos)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Documents uploaded</strong></td>
      <td style="text-align: left">4</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Python files created</strong></td>
      <td style="text-align: left">4</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Cost</strong></td>
      <td style="text-align: left">Claude subscription plan</td>
    </tr>
  </tbody>
</table>

<p>At this point, with the original APK decompiled, we moved to OpenCode to continue developing the test apps and the final solution.</p>

<hr />

<h2 id="part-2-opencode-and-android-app-development">Part 2: OpenCode and Android App Development</h2>

<p>We built the functional Android apps in the second phase using OpenCode, an AI-assisted IDE that runs in a terminal with direct filesystem access, command execution (bash/PowerShell), and code-editing tools. Everything that follows comes from real data in OpenCode’s local SQLite database, not estimates.</p>

<p>OpenCode used 3 different models during the session, all on free tiers:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Model</th>
      <th style="text-align: left">Period</th>
      <th style="text-align: left">Messages</th>
      <th style="text-align: left">Input tokens</th>
      <th style="text-align: left">Output tokens</th>
      <th style="text-align: left">Reasoning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">ling-3.0-flash-fin</td>
      <td style="text-align: left">26/09 (kickoff)</td>
      <td style="text-align: left">231</td>
      <td style="text-align: left">1,859,556</td>
      <td style="text-align: left">85,643</td>
      <td style="text-align: left">39,546</td>
    </tr>
    <tr>
      <td style="text-align: left">longcat-2.5-preview</td>
      <td style="text-align: left">28/09 (exploration)</td>
      <td style="text-align: left">24</td>
      <td style="text-align: left">312,099</td>
      <td style="text-align: left">14,417</td>
      <td style="text-align: left">55,808</td>
    </tr>
    <tr>
      <td style="text-align: left">mimo-v2.6-flash</td>
      <td style="text-align: left">28/09–29/09 (most of the development)</td>
      <td style="text-align: left">483</td>
      <td style="text-align: left">2,171,889</td>
      <td style="text-align: left">218,998</td>
      <td style="text-align: left">256,083</td>
    </tr>
  </tbody>
</table>

<p>738 assistant messages, 4.3 million input tokens, 319K output tokens, and 351K reasoning tokens. We switched between models based on the results: sometimes a model started taking longer or failed to solve problems, possibly because the session context had grown too large, so we tried another model.</p>

<hr />

<h3 id="a-development-with-angitester">a. Development with AngiTester</h3>

<p>Before building the final app (OpenAngi), we needed an Android “lab test bench.” That is how <strong>AngiTester</strong> came about: a lightweight app made of 6 <a href="https://kotlinlang.org/">Kotlin</a> files, whose sole purpose was to interact directly with the real SHS-D33F sensor (all sensors present as SHS-“last 4 digits of MAC”) and validate every hypothesis byte by byte.</p>

<p>AngiTester’s main screen has three key areas for inspecting BLE traffic in real time:</p>

<ul>
  <li><strong>Live command terminal (<code class="language-plaintext highlighter-rouge">log [HH:mm:ss.SSS]</code>)</strong>:
    <ul>
      <li>Console-style view with smart auto-scroll (it pauses when the user scrolls up to read the history and resumes when they return to the bottom).</li>
      <li>Logs all bidirectional traffic (TX/RX), ASCII notifications, status responses, and battery readings in real time every five minutes.</li>
    </ul>
  </li>
  <li><strong>Custom real-time IMU graph (<code class="language-plaintext highlighter-rouge">ImuGraphView.kt</code>)</strong>:
    <ul>
      <li>Plots the accelerometer’s three axes ($g$) and angular velocity ($	ext{°/s}$) at 5 Hz in TEST mode.</li>
      <li>Indexed by sample number (<code class="language-plaintext highlighter-rouge">sampleNumber</code>), avoiding horizontal distortion or “jumps” when BLE notifications arrive in bursts.</li>
      <li>Smoothed Y-axis auto-scaling prevents a sudden peak from flattening the continuous curve.</li>
    </ul>
  </li>
  <li><strong>Direct and extended command buttons</strong>:
    <ul>
      <li>Direct modes (<code class="language-plaintext highlighter-rouge">afd0ffa1</code>): Activate (syncs RTC + MODE <code class="language-plaintext highlighter-rouge">0x01</code>), Normal (<code class="language-plaintext highlighter-rouge">0x00</code>), Test (<code class="language-plaintext highlighter-rouge">0x40</code> IMU streaming).</li>
      <li>Extended commands (<code class="language-plaintext highlighter-rouge">BLOCK_DATA afd0ffa4</code> + trigger <code class="language-plaintext highlighter-rouge">0x20</code>): firmware version (<code class="language-plaintext highlighter-rouge">0xC3</code>), accelerometer/gyroscope test (<code class="language-plaintext highlighter-rouge">0xC0/0xC1</code>), LED control (<code class="language-plaintext highlighter-rouge">0xFE/0xC2</code>), impact simulation (<code class="language-plaintext highlighter-rouge">0xFB</code>), read/report recorded events (<code class="language-plaintext highlighter-rouge">0x80/0x81</code>), among others.</li>
    </ul>
  </li>
</ul>

<p>AngiTester was not merely a test app: it was the empirical tool that allowed us to decipher the hardware’s real behavior and uncover aspects that were unclear in the decompiled code:</p>

<p>i. <strong>Decoding and validating STATUS (<code class="language-plaintext highlighter-rouge">afd0ffa2</code>)</strong>:
   We empirically verified the structure of the 1-byte bit field returned by the sensor:</p>
<ul>
  <li><strong>Bit 7 (<code class="language-plaintext highlighter-rouge">0x80</code>)</strong>: ERROR bit (if 1, the remaining 7 bits are the error code).</li>
  <li><strong>Bits 6-5 (<code class="language-plaintext highlighter-rouge">0x60</code>)</strong>: Mode (<code class="language-plaintext highlighter-rouge">0</code>=NORMAL, <code class="language-plaintext highlighter-rouge">1</code>=EXT_CMD, <code class="language-plaintext highlighter-rouge">2</code>=TEST).</li>
  <li><strong>Bit 4 (<code class="language-plaintext highlighter-rouge">0x10</code>)</strong>: Active session.</li>
  <li><strong>Bit 3 (<code class="language-plaintext highlighter-rouge">0x08</code>)</strong>: Connection heartbeat active.</li>
  <li><strong>Bit 0 (<code class="language-plaintext highlighter-rouge">0x01</code>)</strong>: Last command completed.</li>
</ul>

<p>We catalogued the raw status values observed during operation: <code class="language-plaintext highlighter-rouge">0x00</code> (freshly connected), <code class="language-plaintext highlighter-rouge">0x01</code> (NORMAL + active session), <code class="language-plaintext highlighter-rouge">0x03</code> (active session with pending events), <code class="language-plaintext highlighter-rouge">0x21</code> (EXT_CMD mode completed), and <code class="language-plaintext highlighter-rouge">0x82</code> (ACK with error/transient status following impact commands).</p>

<p>ii. <strong>Discovering and calibrating the accelerometer scale</strong>:
   The original APK divided all values in the IMU report (<code class="language-plaintext highlighter-rouge">IcedotTestReport</code>) by 100. However, when capturing stationary data with AngiTester and processing the evidence (<code class="language-plaintext highlighter-rouge">angi_static.csv</code>), we discovered that axes X, Y, and Z produced a resultant vector of ~1.73 g, which is physically impossible at rest.</p>

<p>Through cross-testing, we derived the true divisor for the accelerometer axes:
   \(ext{Divisor } K = 173.2 pprox 100 \cdot \sqrt{3}\)
   With this correction, the axes reported exactly ~1.00 g at rest (a mean error of only 9 mg), while <code class="language-plaintext highlighter-rouge">accelVector</code> and <code class="language-plaintext highlighter-rouge">rotation</code> retained a divisor of 100.</p>

<p>iii. <strong>Confirming the gyroscope’s single channel</strong>:
   We confirmed that Specialized’s firmware does not transmit the gyroscope’s 3 independent axes over BLE streaming. Instead, it reduces angular velocity to a single scalar channel (<code class="language-plaintext highlighter-rouge">rotation</code>) in °/s (bytes 11-12) and its angular acceleration (<code class="language-plaintext highlighter-rouge">rotationalAccel</code>, bytes 13-14). We determined that this corresponds to a tilting rotation axis (close to the helmet’s X axis).</p>

<p>iv. <strong>Validating the session arming and impact flow</strong>:
   We verified that if the 7-byte UTC RTC (<code class="language-plaintext highlighter-rouge">afd0ffa7</code>) is not sent before enabling <code class="language-plaintext highlighter-rouge">MODE 0x01</code>, impact reports are recorded with a timestamp of 0.
   We captured and validated the real impact notification on <code class="language-plaintext highlighter-rouge">afd0ffa6</code> (a 20-byte packet: type, battery, version, timestamp, peakG, peakR, peakRA, and deviceID).</p>

<figure class="align-center">
  <video controls="" width="100%">
    <source src="/assets/images/sensor_angi/angi_tester.mp4" type="video/mp4" />
    <source src="/assets/images/sensor_angi/angi_tester.MOV" type="video/quicktime" />
    Your browser does not support playing this video.
  </video>
  <figcaption>AngiTester: application execution.</figcaption>
</figure>

<hr />

<h3 id="b-openangi-the-final-independent-app">b. OpenAngi: the final independent app</h3>

<p><strong>OpenAngi</strong> is the final application (30 Kotlin files, a 16.5 MB APK): a complete rewrite from scratch using a modern architecture with Kotlin + Jetpack Compose Material 3 + MVVM + Room + DataStore + Foreground Service.</p>

<p>The app works <strong>without a server, accounts, or a backend</strong>: the phone itself sends SMS messages directly through <code class="language-plaintext highlighter-rouge">SmsManager</code>.</p>

<p><strong>OpenAngi screens:</strong></p>

<p>The app has four main tabs in a bottom bar, plus two overlay screens (Device and Alert):</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Screen</th>
      <th style="text-align: left">What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Scan</strong></td>
      <td style="text-align: left">Lists nearby ANGi sensors (filtered by BLE service, with a name-based SHS- fallback), and shows signal RSSI. Tap a sensor to connect. If a session is already active, a “Scanning paused” banner appears to avoid interfering with the radio.</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Device</strong></td>
      <td style="text-align: left">The main dashboard: animated “Sensor active and listening” pulse, sensor battery, firmware, hardware, raw status, active session, sensor heartbeat, and received impact alerts (diagnostic counter). Buttons: Disconnect, Simulate impact, and Declare emergency.</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Alert</strong></td>
      <td style="text-align: left">Emergency screen: a large countdown (configurable from 5 to 120 seconds), impact G peak, and a large “I’M OK — CANCEL” button. If not cancelled, it shows confirmation with the SMS messages sent and the location. It appears as a full-screen notification if the app is in the background.</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Contacts</strong></td>
      <td style="text-align: left">Add emergency contacts manually or from the phone’s contact picker, edit them, or remove them. When a contact is added, the app automatically sends them an SMS: “You’ve been added as an emergency contact in OpenAngi.”</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>History</strong></td>
      <td style="text-align: left">All recorded events: date, status chip (cancelled/notified/failed), G peaks, and location (tap to open Google Maps). Clear button (cancelled/failed only, or all).</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Settings</strong></td>
      <td style="text-align: left">Countdown duration, sound/vibration, your name (to personalize the SMS), simulate impact, declare emergency, battery-optimization exemption status, and the full legal disclaimer.</td>
    </tr>
  </tbody>
</table>

<figure class="align-center"><a href="/assets/images/sensor_angi/pantallas.png" class="image-popup" title="Screenshots of the OpenAngi Android app, server-free with direct SMS delivery.
"><img src="/assets/images/sensor_angi/pantallas.png" alt="OpenAngi App Screens" /></a><figcaption>
      Screenshots of the OpenAngi Android app, server-free with direct SMS delivery.

    </figcaption></figure>

<p><strong>BLE connection flow</strong></p>

<figure class="align-center"><a href="/assets/images/sensor_angi/flujo_conexion_ble.png" class="image-popup" title="Diagram of the BLE connection and synchronization flow implemented in OpenAngi.
"><img src="/assets/images/sensor_angi/flujo_conexion_ble.png" alt="BLE connection flow" /></a><figcaption>
      Diagram of the BLE connection and synchronization flow implemented in OpenAngi.

    </figcaption></figure>

<p>Every GATT operation goes through a Mutex (one at a time, with an 8-second timeout). If any step fails, automatic reconnection repeats the entire setup from scratch, because the sensor loses its state when disconnected. The backoff grows from 2 to 60 seconds with jitter.</p>

<p><strong>Impact flow: from detection to SMS</strong></p>

<p>This is the app’s central component—the <code class="language-plaintext highlighter-rouge">ImpactFlowController</code> state machine:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/flujo_impact_controller.png" class="image-popup" title="State machine of the ImpactFlowController for impact detection and emergency messaging.
"><img src="/assets/images/sensor_angi/flujo_impact_controller.png" alt="Impact state machine" /></a><figcaption>
      State machine of the ImpactFlowController for impact detection and emergency messaging.

    </figcaption></figure>

<p>The flow runs at the process level, not the UI level: even if the app is closed, as long as the Foreground Service is running, detection works and the full-screen notification opens the alarm directly.</p>

<p><strong>Declaring an emergency:</strong> there is a dedicated button on the Device and Settings screens that triggers exactly the same flow with a synthetic event (type DECLARED, peak 0 g). The SMS says <em>“declared an emergency (manual alert)”</em> instead of <em>“suffered an impact”</em>. Useful if you need help without having had a crash.</p>

<p><strong>Reliability: the Foreground Service</strong></p>

<p>The <code class="language-plaintext highlighter-rouge">AngiMonitorService</code> owns the BLE connection and runs with a persistent notification showing the actual status:</p>
<ul>
  <li>🟢 <strong>Green:</strong> connected and monitoring (shows sensor battery)</li>
  <li>🟡 <strong>Amber:</strong> reconnecting (with a countdown)</li>
  <li>🔴 <strong>Red:</strong> disconnected</li>
</ul>

<p>If there is no connection for 5 minutes, the app displays a high-priority notification: <em>“OpenAngi lost connection to the sensor”</em>. The guiding philosophy is never to fail silently—if something goes wrong, the user should know.</p>

<p>The <code class="language-plaintext highlighter-rouge">BootReceiver</code> reconnects automatically when the phone starts, and if Android blocks background startup, it sends a notification prompting the user to open the app manually.</p>

<figure class="align-center">
  <video controls="" width="100%">
    <source src="/assets/images/sensor_angi/open_angi.mp4" type="video/mp4" />
    Your browser does not support playing this video.
  </video>
  <figcaption>OpenAngi: application execution.</figcaption>
</figure>

<hr />

<h3 id="c-how-it-differs-from-the-original-app-protocol-facts-vs-code">c. How It Differs from the Original App: Protocol Facts vs. Code</h3>

<p>One of the most important aspects of this project is understanding what was taken from Specialized’s original app and what was not—how much the tools copied from the original APK and what was created from scratch. We ran automated scripts comparing the 262,495 lines of the decompiled APK against all the new code:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Check</th>
      <th style="text-align: left">AngiTester</th>
      <th style="text-align: left">OpenAngi</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Lines identical to decompiled code (≥25 chars)</td>
      <td style="text-align: left">2</td>
      <td style="text-align: left">1</td>
    </tr>
    <tr>
      <td style="text-align: left">Shared UI strings</td>
      <td style="text-align: left">0</td>
      <td style="text-align: left">0</td>
    </tr>
    <tr>
      <td style="text-align: left">Copied resources (out of 879 drawables)</td>
      <td style="text-align: left">0</td>
      <td style="text-align: left">0</td>
    </tr>
  </tbody>
</table>

<p>The 3 “identical” lines are idiomatic Android boilerplate (<code class="language-plaintext highlighter-rouge">if (bluetoothManager != null)</code>, <code class="language-plaintext highlighter-rouge">if (cursor.moveToFirst())</code>). The only shared literals are BLE UUIDs—functional data essential for communicating with the sensor.</p>

<p>From the decompilation, we extracted technical protocol facts—the minimum information any app needs to communicate with the sensor:</p>
<ul>
  <li>BLE characteristic UUIDs (<code class="language-plaintext highlighter-rouge">afd0ffa0…ffa8</code>).</li>
  <li>Packet offsets and lengths: the impact report is 20 bytes, the RTC is 7 bytes, and the IMU report is 15 bytes.</li>
  <li>Byte values: modes (<code class="language-plaintext highlighter-rouge">0x00</code> NORMAL, <code class="language-plaintext highlighter-rouge">0x01</code> ACTIVE, <code class="language-plaintext highlighter-rouge">0x20</code> EXT_CMD, <code class="language-plaintext highlighter-rouge">0x40</code> TEST), report types (1=LINEAR, 2=ROTATIONAL, 3=LINEARROTATIONAL), and STATUS bits.</li>
  <li>Numeric error codes (33=SUCCESS, 64=INVALID_PARAM).</li>
  <li>Activation sequence: write RTC + MODE <code class="language-plaintext highlighter-rouge">0x01</code>.</li>
</ul>

<p>All of this is documented and cited in the code comments. These are functional facts—like documenting that “the HTTP port is 80”—not copied creative expression.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Aspect</th>
      <th style="text-align: left">Original app (Specialized Ride)</th>
      <th style="text-align: left">OpenAngi</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Namespace</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">com.specialized.ride</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">com.openangi.app</code></td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Class names</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">IcedotReport</code>, <code class="language-plaintext highlighter-rouge">IcedotBLE</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">ImpactReport</code>, <code class="language-plaintext highlighter-rouge">AngiProtocol</code></td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Packet parsing</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">==20</code> with an exception (<code class="language-plaintext highlighter-rouge">IcedotException</code>)</td>
      <td style="text-align: left">Tolerant <code class="language-plaintext highlighter-rouge">≥16</code> with <code class="language-plaintext highlighter-rouge">parseOrNull</code></td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Architecture</strong></td>
      <td style="text-align: left">Legacy Java code</td>
      <td style="text-align: left">Kotlin + Compose M3 + MVVM + Room</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Emergency alerts</strong></td>
      <td style="text-align: left">Own backend → <code class="language-plaintext highlighter-rouge">POST /events</code> → server sends SMS</td>
      <td style="text-align: left">Direct SMS via <code class="language-plaintext highlighter-rouge">SmsManager</code> (serverless)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>UI / resources</strong></td>
      <td style="text-align: left">XML layouts, Specialized drawables</td>
      <td style="text-align: left">Pure Compose, custom dark theme, original icon</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Backend / URLs</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">api.prod.s-digital.co</code></td>
      <td style="text-align: left">None — zero external dependencies</td>
    </tr>
  </tbody>
</table>

<p>The most significant architectural change is how alerts work: the original app depended on a Specialized backend that received the event through an API and sent the SMS from its server. When Specialized discontinued the service, that stopped working. OpenAngi removes that dependency entirely: the phone sends the SMS directly, without going through any server. It is simpler, more reliable, and does not depend on anyone keeping a backend alive.</p>

<p>In summary: from the original APK we took the protocol data (UUIDs, packet formats, activation sequence)—technical facts required to interoperate with the hardware—and built an entirely new app with a different architecture, different UI, and a fundamentally different alert mechanism.</p>

<hr />

<h3 id="d-overall-software-development-summary">d. Overall Software Development Summary</h3>

<p>The numbers for the entire project reflect the scale of the AI-assisted work:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Metric</th>
      <th style="text-align: left">Claude</th>
      <th style="text-align: left">OpenCode</th>
      <th style="text-align: left">Total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Models used</strong></td>
      <td style="text-align: left">1</td>
      <td style="text-align: left">3</td>
      <td style="text-align: left">4</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>User turns / prompts</strong></td>
      <td style="text-align: left">26</td>
      <td style="text-align: left">75</td>
      <td style="text-align: left">101</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Assistant messages</strong></td>
      <td style="text-align: left">26</td>
      <td style="text-align: left">738</td>
      <td style="text-align: left">764</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Tool calls</strong></td>
      <td style="text-align: left">~54</td>
      <td style="text-align: left">1,067</td>
      <td style="text-align: left">~1,121</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Code files created</strong></td>
      <td style="text-align: left">4 (Python)</td>
      <td style="text-align: left">36+ (Kotlin)</td>
      <td style="text-align: left">40+</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Cost</strong></td>
      <td style="text-align: left">Claude subscription</td>
      <td style="text-align: left">$0 (free tier)</td>
      <td style="text-align: left">—</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Period</strong></td>
      <td style="text-align: left">1 session</td>
      <td style="text-align: left">3 days (26–29/09)</td>
      <td style="text-align: left">~3 days</td>
    </tr>
  </tbody>
</table>

<p>The table below breaks down the tools used in OpenCode:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Tool</th>
      <th style="text-align: left">Calls</th>
      <th style="text-align: left">Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">bash</td>
      <td style="text-align: left">378</td>
      <td style="text-align: left">Gradle, Python, git, apksigner, system commands</td>
    </tr>
    <tr>
      <td style="text-align: left">edit</td>
      <td style="text-align: left">268</td>
      <td style="text-align: left">Surgical code edits (search &amp; replace)</td>
    </tr>
    <tr>
      <td style="text-align: left">read</td>
      <td style="text-align: left">241</td>
      <td style="text-align: left">Reading project files and decompiled code</td>
    </tr>
    <tr>
      <td style="text-align: left">write</td>
      <td style="text-align: left">103</td>
      <td style="text-align: left">Creating new files</td>
    </tr>
    <tr>
      <td style="text-align: left">grep</td>
      <td style="text-align: left">54</td>
      <td style="text-align: left">Searching for patterns in code</td>
    </tr>
    <tr>
      <td style="text-align: left">glob</td>
      <td style="text-align: left">11</td>
      <td style="text-align: left">Searching for files by name</td>
    </tr>
    <tr>
      <td style="text-align: left">todowrite</td>
      <td style="text-align: left">6</td>
      <td style="text-align: left">Task list updates</td>
    </tr>
    <tr>
      <td style="text-align: left">MCP codebase-memory</td>
      <td style="text-align: left">4</td>
      <td style="text-align: left">Codebase knowledge graph</td>
    </tr>
    <tr>
      <td style="text-align: left">task (subagent)</td>
      <td style="text-align: left">1</td>
      <td style="text-align: left">Delegating to a child agent</td>
    </tr>
  </tbody>
</table>

<p>In addition: 720 reasoning traces, 256 text responses, and 8 context compactions (when the context window fills up, the model compresses the history and continues).</p>

<p><strong>What did we learn from the process?</strong></p>

<p>AI did not replace the work—we were still the ones connecting the sensor, running scripts, taking photos, opening the device, decompiling the APK, and making design decisions. But it dramatically accelerated every intermediate step: instead of searching datasheets, reading forums, and writing byte parsers by hand, conversations with the models took us from “what is this chip?” to “we have a signed, working APK” in 3 days.</p>

<p>It is even one of the lines of research we are incorporating into the PID: closing the loop—granting LLMs access to hardware and enabling interaction with physical tools—to further accelerate the experimentation process.</p>

<p>The combination of tools worked well:</p>
<ul>
  <li><strong>Claude</strong> (web chat with vision, search, and image analysis) was ideal for the exploratory phase: identifying chips from photos, putting together an attack plan, and building incremental scripts.</li>
  <li><strong>OpenCode</strong> (filesystem access, bash/PowerShell command execution, and code editing) was ideal for the construction phase: compiling, fixing Gradle errors, and iterating over 30 Kotlin files until we had a functional APK.</li>
</ul>

<p>All Android app development—738 messages, 4.3 million input tokens, and more than a thousand tool calls—cost exactly zero dollars using free models.</p>

<hr />

<h2 id="part-3-hardware-and-firmware--comparative-analysis-antigravity-vs-opencode">Part 3: Hardware and Firmware – Comparative Analysis (Antigravity vs. OpenCode)</h2>

<p>With the app running, we turned to the hardware itself: the sensor PCB has six test pads arranged in the standard <a href="https://www.tag-connect.com/product/tc2030-idc-6-pin-plug-of-nails-spring-pin-cable-with-ribbon-connector">Tag-Connect TC2030</a> footprint.</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/diy_tag_connect.png" class="image-popup" title="Handmade Tag-Connect programming cable built to dump the ANGi sensor’s nRF52832 flash.
"><img src="/assets/images/sensor_angi/diy_tag_connect.png" alt="DIY Tag-Connect connector" /></a><figcaption>
      Handmade Tag-Connect programming cable built to dump the ANGi sensor’s nRF52832 flash.

    </figcaption></figure>

<p>We connected a J-Link via SWD, and since the manufacturer had not enabled read protection on the nRF52832 (<code class="language-plaintext highlighter-rouge">APPROTECT</code>), we were able to dump the entire 512 KB flash memory over SWD without restrictions.</p>

<p>This stage presented an interesting opportunity: having already completed the reverse engineering of the protocol from the APK, we used the extracted firmware to compare two AI coding environments working in parallel on the same problem: <strong>Antigravity</strong> (Google’s agentic IDE) and <strong>OpenCode</strong>.</p>

<hr />

<h3 id="a-initial-extraction-and-decompilation-with-claude-and-ghidra">a. Initial Extraction and Decompilation with Claude and Ghidra</h3>

<p>We dumped the firmware using OpenOCD and a Segger J-Link probe over SWD:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/tag_connect.jpg" class="image-popup" title="Tag-Connect programming pads on the ANGi sensor PCB: SWDIO, SWDCLK, VCC, GND, and RESET.
"><img src="/assets/images/sensor_angi/tag_connect.jpg" alt="Tag-Connect pads on the PCB" /></a><figcaption>
      Tag-Connect programming pads on the ANGi sensor PCB: SWDIO, SWDCLK, VCC, GND, and RESET.

    </figcaption></figure>

<p>The dump yielded a 512 KB <code class="language-plaintext highlighter-rouge">.bin</code> file that we analyzed with <a href="https://ghidra-sre.org/">Ghidra</a> (the open-source reverse engineering framework).</p>

<p>We turned to Claude (Sonnet 5.5) to assist in the initial Ghidra analysis. We provided the memory map and string references, and Claude guided the setup of memory segments, identify the interrupt vector table, and distinguish Nordic SoftDevice calls (SVCs) from application code:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/nrf_dump.png" class="image-popup" title="Memory structure of the nRF52832 in Ghidra: SoftDevice S132, Application space, and Bootloader.
"><img src="/assets/images/sensor_angi/nrf_dump.png" alt="Ghidra memory map of the nRF52832" /></a><figcaption>
      Memory structure of the nRF52832 in Ghidra: SoftDevice S132, Application space, and Bootloader.

    </figcaption></figure>

<p>Claude also generated a Ghidra Java script (<code class="language-plaintext highlighter-rouge">AngiAutoAnalyzer.java</code>) to automatically label known Nordic SDK 14 functions and annotate BLE event handlers.</p>

<p>With the disassembly baseline established, we provided the raw Ghidra output to both Antigravity and OpenCode to assess their decompilation, analysis, and hardware-reconstruction capabilities.</p>

<hr />

<h3 id="b-comparative-summary-antigravity-vs-opencode">b. Comparative Summary: Antigravity vs. OpenCode</h3>

<p>We set both tools the task of analyzing the decompiled firmware: identify sensor initialization, locate the impact-detection logic, extract BLE handlers, and identify any debug/test interfaces.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Capability</th>
      <th style="text-align: left">Antigravity (Gemini 3.6 Flash)</th>
      <th style="text-align: left">OpenCode (big-pickle)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Model</strong></td>
      <td style="text-align: left">Gemini 3.6 Flash</td>
      <td style="text-align: left">big-pickle (free tier)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Approach</strong></td>
      <td style="text-align: left">Hardware-first: test points, UART, pins</td>
      <td style="text-align: left">Code-first: event architecture, flash map</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>UART discovery</strong></td>
      <td style="text-align: left">Identified <code class="language-plaintext highlighter-rouge">P0.30</code> as test trigger and 38,400 baud</td>
      <td style="text-align: left">Identified UART functions, missed trigger pin</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Hardware validation</strong></td>
      <td style="text-align: left">High: guided oscilloscope and multimeter testing</td>
      <td style="text-align: left">Medium: focused on static code analysis</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>C code quality</strong></td>
      <td style="text-align: left">Prototypes, register maps, and comments (no function bodies)</td>
      <td style="text-align: left">77,793 lines of C; duplicated function bodies</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Sensor bus</strong></td>
      <td style="text-align: left">Hallucinated hardware SPI (actually software bit-banging)</td>
      <td style="text-align: left">Correctly identified register-level sensor access</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Impact algorithm</strong></td>
      <td style="text-align: left">Described high-level thresholds from strings</td>
      <td style="text-align: left">Analyzed <code class="language-plaintext highlighter-rouge">impact_detector_config</code> (0x2F60C) in detail</td>
    </tr>
  </tbody>
</table>

<hr />

<h3 id="c-main-achievements-of-each-tool">c. Main Achievements of Each Tool</h3>

<h4 id="antigravity-hardware-insight-and-uart-console">Antigravity: Hardware Insight and UART Console</h4>

<p>Antigravity’s biggest contribution was bridging the gap between firmware and physical hardware. It not only identified the presence of a factory test console in the code, but deduced the exact GPIO pin (<code class="language-plaintext highlighter-rouge">P0.30</code>) used to trigger it, the required baud rate (38,400), and guided the physical search for those signals among the PCB test points using an oscilloscope.</p>

<h4 id="opencode-firmware-decompilation-and-architecture">OpenCode: Firmware Decompilation and Architecture</h4>

<p>OpenCode excelled at deep static analysis of the firmware binary. It produced a comprehensive 77,793-line annotated C file, mapped the event-driven architecture centered around <code class="language-plaintext highlighter-rouge">evt_set</code>, identified the 24-byte telemetry buffer structure, and performed the most detailed decompilation of the fall-detection algorithm’s configuration and processing routines.</p>

<hr />

<h3 id="d-finding-the-uart-console-and-validating-it-on-the-board">d. Finding the UART Console and Validating It on the Board</h3>

<p>Identifying the PCB test points was a much more hands-on process: we used a multimeter and oscilloscope to identify the physical signals. GND and VCC were easy to locate from the battery holder.</p>

<p>Once the UART and console pin were confirmed, the task was to find them on the PCB. We observed activity on all test points while powering the device and found six with pulse signals (sensor communication), another six held low, and a single test point held high (3 V).</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/test_points.png" class="image-popup" title="Test points identified on the ANGi PCB. TM is pin P0.30 (Test Mode).
"><img src="/assets/images/sensor_angi/test_points.png" alt="Test points on the ANGi PCB" /></a><figcaption>
      Test points identified on the ANGi PCB. TM is pin P0.30 (Test Mode).

    </figcaption></figure>

<p>At first, we expected to find at least two test points high, corresponding to the UART’s idle state. When we asked Antigravity, it explained that the UART is not configured in normal mode to save power, and is only enabled at startup when <code class="language-plaintext highlighter-rouge">P0.30</code> is held low. This led us to think that the only high test point should be the test-mode pin (<code class="language-plaintext highlighter-rouge">P0.30</code>) with a pull-up.</p>

<p>We described the situation to the model:</p>

<p><strong>👤 User (step 258):</strong></p>
<blockquote>
  <p><em>“Another question about the test points: one is at VCC, six show some kind of signal/pulses, and six show a low signal to GND. Could it be that the UART isn’t configured unless it is in test mode? Why would I expect to see two more UART pins idle at 3 V?”</em></p>
</blockquote>

<p><strong>🤖 Antigravity (step 259):</strong></p>
<blockquote>
  <p>*“Exactly! Your deduction is 100% correct.</p>

  <p><strong>1. Why don’t you see the UART at 3 V (idle) in normal mode?</strong><br />
On Nordic nRF52 microcontrollers, the UART is not permanently assigned to fixed hardware pins….</p>

  <p><strong>2. Explanation of the signals you are currently measuring (Normal Mode)</strong></p>
  <ul>
    <li><strong>1 test point at VCC (3 V):</strong> Main battery / VDD supply.</li>
    <li><strong>6 test points with pulses/signals:</strong> Bit-banged communication bus for the MEMS sensors (LIS3DH + gyroscope): clock, MOSI, MISO, chip selects (CS). Impact/movement interrupts (INT1 / INT2).</li>
    <li><strong>6 test points at GND:</strong> Disabled pins, unused inputs, or PCB ground pads (GND).”*</li>
  </ul>
</blockquote>

<p><strong>👤 User (step 260):</strong></p>
<blockquote>
  <p><em>“I can’t access the pins; the nRF52 is a BGA. Would it be plausible for the test point at VCC to be that pin with a pull-up?”</em></p>
</blockquote>

<p><strong>🤖 Antigravity (step 261):</strong></p>
<blockquote>
  <p><em>“Yes, that is HIGHLY likely! It is the standard factory-test architecture for boards with a BGA package.”</em></p>
</blockquote>

<p>This response caught us somewhat off guard, as we could not identify what technical reasoning connected the nRF52’s BGA package to the conclusion that this specific test point corresponded to a pull-up. While we had provided several hints to see if the model could follow our own reasoning, its conclusion appeared to reach beyond what the available context supported at that moment.</p>

<p>To test the theory, we forced <code class="language-plaintext highlighter-rouge">P0.30</code> low during power-up: the LEDs flashed in a different sequence, confirming test mode. When we measured again with the oscilloscope, two test points that had previously been low transitioned to high (UART idle level).</p>

<p><strong>👤 User (step 262):</strong></p>
<blockquote>
  <p><em>“I grounded it, and on power-up the red and green LEDs flashed, and TP10 and TP12 transitioned to VCC. Those must be TX and RX, right?”</em></p>
</blockquote>

<p><strong>🤖 Antigravity (step 263):</strong></p>
<blockquote>
  <p><em>“GREAT NEWS! 🎉 You successfully entered Factory Test Mode!”</em></p>
</blockquote>

<p>Next, we connected a USB-to-serial converter to both pins. Through trial and error, we determined which test point was RX and which was TX, and verified the actual baud rate of <strong>38,400 baud</strong>:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/uart.jpg" class="image-popup" title="Physical connection of the USB-to-serial converter to the UART test points on the ANGi sensor board.
"><img src="/assets/images/sensor_angi/uart.jpg" alt="UART serial connection on the PCB" /></a><figcaption>
      Physical connection of the USB-to-serial converter to the UART test points on the ANGi sensor board.

    </figcaption></figure>

<p>With Realterm configured for 38,400 baud and the device in test mode, we interacted with the production console and verified its command set:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/terminal.png" class="image-popup" title="Serial terminal interacting at 38,400 baud with the firmware’s production commands.
"><img src="/assets/images/sensor_angi/terminal.png" alt="Serial terminal interacting with ANGi" /></a><figcaption>
      Serial terminal interacting at 38,400 baud with the firmware’s production commands.

    </figcaption></figure>

<p>Test mode allows the device’s operating mode to be changed from an RF perspective for certification testing. This aligns exactly with the photographic documentation on the FCC website: the device powered by an external supply, with three wires exiting through one side (UART TX/RX and GND):</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/angi_fcc_test_setup.png" class="image-popup" title="Test setup documented with the FCC: external power and a serial connection for ANGi sensor RF certification tests.
"><img src="/assets/images/sensor_angi/angi_fcc_test_setup.png" alt="Test setup reported to the FCC" /></a><figcaption>
      Test setup documented with the FCC: external power and a serial connection for ANGi sensor RF certification tests.

    </figcaption></figure>

<hr />

<h3 id="e-ble-operating-modes">e. BLE Operating Modes</h3>

<p>The firmware analysis and UART validation allowed us to reconstruct the ANGi sensor’s complete state machine across all operating modes:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/flujo_estados_dispositivo.png" class="image-popup" title="Reconstructed operating modes of the Specialized ANGi sensor.
"><img src="/assets/images/sensor_angi/flujo_estados_dispositivo.png" alt="ANGi sensor operating modes" /></a><figcaption>
      Reconstructed operating modes of the Specialized ANGi sensor.

    </figcaption></figure>

<ol>
  <li><strong>Low Power / Sleep:</strong> Default state on the shelf or between rides. The nRF52 enters System ON sleep; the LIS3DH accelerometer remains configured for threshold-based wake-on-motion. Current draw drops to ~21 µA.</li>
  <li><strong>Factory / Production Test:</strong> Entered by pulling <code class="language-plaintext highlighter-rouge">P0.30</code> low during power-on. Enables the 38,400-baud UART console, provides direct register access, and supports RF test modes for regulatory certification.</li>
  <li><strong>Normal Connected (Ride Mode):</strong> The sensor pairs with the phone, writes RTC UTC, enables <code class="language-plaintext highlighter-rouge">MODE 0x01</code>, and enters continuous crash-monitoring mode. Periodic heartbeats maintain connection state. Average current draw is ~62 µA.</li>
  <li><strong>Test / IMU Streaming:</strong> Enabled via <code class="language-plaintext highlighter-rouge">MODE 0x40</code>. Streams raw accelerometer and gyroscope data at 5 Hz over BLE characteristic <code class="language-plaintext highlighter-rouge">afd0ffa8</code>. Used by AngiTester for sensor calibration.</li>
</ol>

<hr />

<h3 id="f-fall-detection-algorithm">f. Fall Detection Algorithm</h3>

<p>To understand how ANGi decides that a fall has occurred, we consulted several models and compared their answers with the firmware. Gemini (Gemini 3.6 Flash), ChatGPT (GPT 5.6 Luna), and Copilot all pointed to two relevant parts: impact configuration for the <strong>LIS3DH</strong> accelerometer and triaxial analysis by the <strong>L3GD20H</strong> gyroscope. Their explanations, however, did not show the same degree of caution. ChatGPT acknowledged several unknowns; Gemini proposed a more complete sequence but presented some hypotheses as facts that the available code did not prove. For example, it described a buffer as circular and pre-impact, and interpreted the <strong>8</strong> and <strong>2</strong> counts as confirmation samples.</p>

<p>Reviewing the C code later showed that these interpretations went beyond the evidence: the code writes samples, but that does not prove they are later read to decide whether a fall occurred; the values <strong>8</strong> and <strong>2</strong> appear as parameters, but we could not find who consumes them or what their timing means. OpenCode provided a more detailed reading of both branches. In its analysis, <code class="language-plaintext highlighter-rouge">impact_detector_config</code> (<code class="language-plaintext highlighter-rouge">0x2F60C</code>) sets a default threshold of <strong>16000</strong> and <strong>8</strong> counts for the accelerometer. When an interrupt occurs, it calculates the magnitude of the vector:</p>

\[ext{magnitud} = \sqrt{X^2 + Y^2 + Z^2}\]

<p>It checks whether it exceeds that threshold and whether the difference with the previous reading is greater than <strong>1000</strong>. The routine records maximums, counts impacts, and signals an event. The gyroscope branch (<code class="language-plaintext highlighter-rouge">0x2FAF8</code>) sets a threshold of <strong>1000</strong> and <strong>20</strong> counts. It evaluates the difference between consecutive values:</p>

\[ext{dif}_i = 	ext{val}_i - 	ext{prev}_i\]

<p>and accumulates:</p>

\[ext{acc}_i = 	ext{dif}_i + (	ext{acc}_i - 	ext{acc}_i/4)\]

<p>It checks whether $	ext{dif}_i &gt; 1000$ and whether $	ext{acc}_i$ exceeds <strong>20</strong>. It also updates counters and minima, can store <code class="language-plaintext highlighter-rouge">X, Y, Z, 0xAAAA</code>, and signals an event. The sensor’s identity is confirmed; the units and physical meaning of these values are not. “Energy” and <em>jerk</em> are analogies, not verified conclusions.</p>

<p>A LIS3DH task publishes telemetry while the L3GD20H gyroscope routine analyzes samples. This confirms parallel activity, but not that the two branches converge on a single decision. We have not yet reconstructed the criterion that turns these signals into a confirmed fall or the path that generates an alarm.</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/algoritmo_deteccion.png" class="image-popup" title="Firmware operations observed in the sensor-analysis branches; relationships that have not been confirmed are shown as unknowns.
"><img src="/assets/images/sensor_angi/algoritmo_deteccion.png" alt="Fall detection algorithm diagram" /></a><figcaption>
      Firmware operations observed in the sensor-analysis branches; relationships that have not been confirmed are shown as unknowns.

    </figcaption></figure>

<p>We could continue with a more exhaustive analysis, but considered the work completed sufficient for the project’s goals and activities. What is certain—and confirmed by the analysis of the APK—is that the device reports three types of events or alarms: Linear, Rotational, and Linear+Rotational.</p>

<hr />

<h3 id="g-critical-review-of-the-c-files-and-detected-hallucinations">g. Critical Review of the C Files and Detected Hallucinations</h3>

<p>A close audit of the delivered <code class="language-plaintext highlighter-rouge">.c</code> files revealed strengths, but also inconsistencies and typical LLM hallucinations:</p>

<p><strong><code class="language-plaintext highlighter-rouge">app_annotated.c</code> (Antigravity)</strong>
Although its Markdown report was very accurate about the UART console, the delivered C file (630 lines) was incomplete:</p>
<ul>
  <li><strong>Missing decompiled code:</strong> It contained only function prototypes, register tables, and comments; it did not include any function bodies.</li>
  <li><strong>Technical inconsistencies:</strong>
    <ul>
      <li>It claimed the sensors used hardware SPI0/TWI0 and SPI1/TWI1 peripheral buses, while the firmware actually implements software bit-banging.</li>
      <li>It identified the gyroscope as an L3GD20 with <code class="language-plaintext highlighter-rouge">WHO_AM_I</code> register value <code class="language-plaintext highlighter-rouge">0xD4</code> or <code class="language-plaintext highlighter-rouge">0xD7</code> (in fact, <code class="language-plaintext highlighter-rouge">0xD7</code> corresponds to the L3GD20H).</li>
    </ul>
  </li>
</ul>

<p><strong><code class="language-plaintext highlighter-rouge">app_decompiled_annotated.c</code> (OpenCode)</strong>
At 1.9 MB and 77,793 lines of Ghidra decompiled code, this was a massive deliverable, but suffered from structural flaws:</p>
<ul>
  <li><strong>Duplicated function bodies:</strong> Of the 134 renamed functions, approximately 79 contained the new annotated body followed immediately by the original Ghidra body with a stray <code class="language-plaintext highlighter-rouge">{</code> bracket, preventing direct compilation.</li>
  <li><strong>UART trigger confusion:</strong> The <code class="language-plaintext highlighter-rouge">main()</code> code checks whether <code class="language-plaintext highlighter-rouge">P0.30</code> is held low to flash 3 times and enter <code class="language-plaintext highlighter-rouge">rf_test_console_poll()</code>. OpenCode misinterpreted this check as “button detection / charging contact.”</li>
  <li><strong>Register map errors:</strong> It assumed the nRF52832 had a “P1” pin port (it only has port P0 with 32 GPIOs), confused the GPIO register base <code class="language-plaintext highlighter-rouge">0x50000500</code>, and discarded the sensor’s <code class="language-plaintext highlighter-rouge">0xD7</code> response because it did not recognize it as the L3GD20H.</li>
</ul>

<p><strong>Final Verdict and Optimal Workflow</strong></p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Aspect</th>
      <th style="text-align: left">Antigravity (Gemini 3.6 Flash)</th>
      <th style="text-align: left">OpenCode (big-pickle)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Delivered C code</strong></td>
      <td style="text-align: left">Prototypes, structures, and comments only (26 KB)</td>
      <td style="text-align: left">Full named C decompilation (1.9 MB)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Internal coherence</strong></td>
      <td style="text-align: left">Low (.c contradicts findings in the .md report)</td>
      <td style="text-align: left">Medium (sound architecture analysis, duplicated C functions)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>High-impact findings</strong></td>
      <td style="text-align: left">UART console, pin P0.30, baud rate, and TPs (verified on chip)</td>
      <td style="text-align: left">Event architecture (<code class="language-plaintext highlighter-rouge">evt_set</code>), 24 B bus, flash map</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Hallucinations / Errors</strong></td>
      <td style="text-align: left">False hardware SPI, omitted function bodies</td>
      <td style="text-align: left">nRF52 GPIO register confusion, duplicated brackets in C</td>
    </tr>
  </tbody>
</table>

<p>Neither tool delivered a perfect result on its own, but together they complemented each other: Antigravity excelled at physical and hardware validation; OpenCode excelled at decompilation breadth and architecture. The optimal workflow consisted of taking OpenCode’s decompilation base, stripping duplicates via script, and incorporating the hardware parameters discovered by Antigravity.</p>

<hr />

<h3 id="h-power-measurements-and-electrical-characterization">h. Power Measurements and Electrical Characterization</h3>

<p>To conclude the hardware investigation, we measured power consumption with the device in different states using Nordic Semiconductor’s <a href="https://www.nordicsemi.com/Products/Development-hardware/Power-Profiler-Kit-2">Power Profiler Kit II (PPK2)</a>.</p>

<p>The tool measures currents from 200 nA to 1 A with a 100 ksps sampling rate. We connected the ANGi to the PPK2, powering it at 3.0 V (simulating a CR2032 coin cell), and tested three representative operating modes from the device’s life cycle:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/ppk2.jpg" class="image-popup" title="Measurement setup with the Nordic Power Profiler Kit II (PPK2) connected to the ANGi sensor to characterize real-time power consumption.
"><img src="/assets/images/sensor_angi/ppk2.jpg" alt="PPK2 measurement setup" /></a><figcaption>
      Measurement setup with the Nordic Power Profiler Kit II (PPK2) connected to the ANGi sensor to characterize real-time power consumption.

    </figcaption></figure>

<hr />

<h4 id="i-normal-connected-activity-monitoring-during-a-ride">i. Normal Connected Activity (Monitoring During a Ride)</h4>

<p>Once the sensor wakes up (from movement) and successfully connects to the phone, it enters its normal operating regime: the processor monitors the accelerometer/gyroscope and periodically sends a heartbeat over BLE:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/se_despierta_y_conecta_anotado.png" class="image-popup" title="PPK2 measurement: Cyan, low power/sleep; Yellow, wake-on-motion and connection search; Pink, Connected, BLE activity and normal consumption.
"><img src="/assets/images/sensor_angi/se_despierta_y_conecta_anotado.png" alt="Power consumption while waking and connecting" /></a><figcaption>
      PPK2 measurement: Cyan, low power/sleep; Yellow, wake-on-motion and connection search; Pink, Connected, BLE activity and normal consumption.

    </figcaption></figure>

<p>Analyzing the stable interval (from second 20 of the capture onward, duration 41.86 s):</p>
<ul>
  <li><strong>Average current:</strong> 0.062 mA (62.1 µA)</li>
  <li><strong>Maximum current:</strong> 21.64 mA (BLE radio spikes)</li>
  <li><strong>Minimum current:</strong> 0.004 mA (3.9 µA in inter-event sleep)</li>
  <li><strong>Charge consumed:</strong> 0.000722 mAh</li>
  <li><strong>Estimated lifetime (continuous CR2032):</strong> 3,706.6 hours (~154.4 days)</li>
</ul>

<hr />

<h4 id="ii-connection-search-and-low-power-sleep--standby">ii. Connection Search and Low Power (Sleep / Standby)</h4>

<p>When the sensor powers on or detects motion, it attempts to connect during an initial window (15 to 38 seconds). If it finds no authorized phone, it terminates transmission and enters deep low-power sleep after second 40:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/se_enciende_no_se_conecta_anotado.png" class="image-popup" title="PPK2 measurement: Green, wake-on-motion; Yellow, connection search without finding a host; Cyan, low power/sleep.
"><img src="/assets/images/sensor_angi/se_enciende_no_se_conecta_anotado.png" alt="Power consumption during search and standby" /></a><figcaption>
      PPK2 measurement: Green, wake-on-motion; Yellow, connection search without finding a host; Cyan, low power/sleep.

    </figcaption></figure>

<p>Analyzing the low-power period (seconds 40 to 90.8, duration 50.85 s):</p>
<ul>
  <li><strong>Average current:</strong> 0.021 mA (21.0 µA)</li>
  <li><strong>Maximum current:</strong> 0.024 mA (24.1 µA)</li>
  <li><strong>Minimum current:</strong> 0.019 mA (18.8 µA)</li>
  <li><strong>Charge consumed:</strong> 0.000297 mAh</li>
  <li><strong>Estimated lifetime (continuous CR2032):</strong> 10,949.6 hours (~456.2 days / 1.25 years)</li>
</ul>

<hr />

<h4 id="iii-intensive-search-sequence-after-disconnection">iii. Intensive Search Sequence After Disconnection</h4>

<p>If the sensor loses BLE connection with the phone during a ride (for example, by moving out of range or signal drop), it initiates an intensive sequence of repeated retries (~15 attempts across a 180 to 900-second window) to re-establish the session:</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/buscaba_se_conecta_luego_se_desconecta_anotado.png" class="image-popup" title="PPK2 measurement: Yellow, search and connection attempt; Pink, Connected, BLE activity and normal consumption; Red, reconnection attempts following disconnection.
"><img src="/assets/images/sensor_angi/buscaba_se_conecta_luego_se_desconecta_anotado.png" alt="Search, connection, and disconnection" /></a><figcaption>
      PPK2 measurement: Yellow, search and connection attempt; Pink, Connected, BLE activity and normal consumption; Red, reconnection attempts following disconnection.

    </figcaption></figure>

<figure class="align-center"><a href="/assets/images/sensor_angi/prende_busca_no_conecta_se_mueve_busca_conecta_desconecta_no_reconecta_anotado.png" class="image-popup" title="PPK2 measurement: Cyan, low power; Green, wake-on-motion; Yellow, search without finding host; Pink, Connected, BLE activity and normal consumption; Red, reconnection attempts following disconnection.
"><img src="/assets/images/sensor_angi/prende_busca_no_conecta_se_mueve_busca_conecta_desconecta_no_reconecta_anotado.png" alt="Failed reconnection cycle" /></a><figcaption>
      PPK2 measurement: Cyan, low power; Green, wake-on-motion; Yellow, search without finding host; Pink, Connected, BLE activity and normal consumption; Red, reconnection attempts following disconnection.

    </figcaption></figure>

<p>Analyzing the search burst period (seconds 180 to 900, duration 720 s):</p>
<ul>
  <li><strong>Average current:</strong> 1.878 mA (1877.8 µA)</li>
  <li><strong>Maximum current:</strong> 23.73 mA (continuous advertising and RF scanning peaks)</li>
  <li><strong>Minimum current:</strong> 0.004 mA (3.7 µA)</li>
  <li><strong>Charge consumed:</strong> 0.375561 mAh</li>
  <li><strong>Estimated lifetime (if searching 100% of the time):</strong> 122.5 hours (~5.1 days)</li>
</ul>

<hr />

<h4 id="summary-of-power-consumption-and-estimated-battery-life">Summary of Power Consumption and Estimated Battery Life</h4>

<table>
  <thead>
    <tr>
      <th style="text-align: left">State / Mode</th>
      <th style="text-align: left">Average Current</th>
      <th style="text-align: left">Max / Min Current</th>
      <th style="text-align: left">Estimated CR2032 lifetime (225 mAh)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Connected during a ride (monitoring)</strong></td>
      <td style="text-align: left">62.1 µA (0.062 mA)</td>
      <td style="text-align: left">21.64 mA / 3.9 µA</td>
      <td style="text-align: left">~154.4 days (continuous)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Low power (standby / sleep)</strong></td>
      <td style="text-align: left">21.0 µA (0.021 mA)</td>
      <td style="text-align: left">24.1 µA / 18.8 µA</td>
      <td style="text-align: left">~456.2 days (~1.25 years)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Active search (reconnection)</strong></td>
      <td style="text-align: left">1.878 mA (1877.8 µA)</td>
      <td style="text-align: left">23.73 mA / 3.7 µA</td>
      <td style="text-align: left">~5.1 days (if continuous)</td>
    </tr>
  </tbody>
</table>

<p>Specialized’s manual specifies a typical battery life of 6 months with rides of 6 to 8 hours per week. Using the empirical PPK2 measurements, we can technically validate this estimate:</p>

<ul>
  <li>For 7 hours per week connected (pedaling at 62.1 µA):<br />
<code class="language-plaintext highlighter-rouge">7 h × 0.0621 mA = 0.435 mAh/week</code></li>
  <li>For the remaining time (161 hours per week) in standby (at 21.0 µA):<br />
<code class="language-plaintext highlighter-rouge">161 h × 0.0210 mA = 3.381 mAh/week</code></li>
  <li><strong>Estimated total consumption:</strong> ~3.816 mAh/week.</li>
</ul>

<p>For a CR2032 coin cell with a nominal usable capacity of 225 mAh:<br />
<strong>Theoretical lifetime</strong> = 225 mAh / 3.816 mAh/week ≈ <strong>58.9 weeks</strong> (~13.5 months)</p>

<p>Considering natural CR2032 self-discharge, voltage drops caused by repeated 21–23 mA BLE radio spikes, outdoor temperature variation, and occasional searches after disconnection, the manufacturer’s 6-month estimate is highly realistic and conservative from a hardware-engineering perspective.</p>

<hr />

<h2 id="part-4-dfu-firmware-update">Part 4: DFU Firmware Update</h2>

<p>The disassembly process of the original sensor was not particularly harmless; the PCB ended up with issues, and opening the enclosure was far from delicate, damaging the sealing gasket (IP67). So I bought a couple of new sensors. It turned out that these sensors came with an older firmware and were overly sensitive to impacts, and with the official app discontinued, there was no way to update them. The initial idea was to try using the firmware extracted with the J-Link and upload it over Bluetooth—this time without damaging the sensor.</p>

<h3 id="a-building-the-package-and-first-attempt-with-claude">a. Building the Package and First Attempt with Claude</h3>

<p>We provided the dump to Claude (Sonnet 5.5 on claude.ai), and in a few iterations we organized the memory map: MBR, SoftDevice S132 v3.0.0, an application of approximately 79 KB, a Nordic Secure DFU bootloader, and a settings page whose CRC matched that of the app. We built a DFU package using <a href="https://www.nordicsemi.com/Products/Development-tools/nRF-Util">nRF Util</a> and a Python client with bleak that reboots the sensor into bootloader mode, uploads the package, and logs every step. Claude tested it against a simulated bootloader, but real hardware steered the corrections: it looked for the wrong DFU UUID (the sensor uses the experimental Nordic SDK 12.x service, <code class="language-plaintext highlighter-rouge">8E400001</code>), and the log revealed the correct one.</p>

<p>With that correction, the bootloader received the init packet properly (the CRC matched), but rejected it during validation with a generic error (<code class="language-plaintext highlighter-rouge">INVALID_OBJECT</code>), which in this bootloader covers signature, version, or format without specifying which.</p>

<h3 id="b-inspecting-the-apk-with-antigravity-and-finding-the-embedded-firmware">b. Inspecting the APK with Antigravity and Finding the Embedded Firmware</h3>

<p>That is when we switched tools. With Antigravity (Claude Sonnet 4.6 in Thinking mode and, briefly, Gemini 3.1 Pro in Low), we re-examined the decompiled APK. We found the code the official app uses to assemble the DFU and, even better, the official firmware embedded within its resources: <code class="language-plaintext highlighter-rouge">shs_v2_6_13.zip</code>, the exact same version 2.6.13 that had appeared in the dump strings. Analyzing previous logs pointed to a firmware version validation check. With that insight, the script was rewritten and the procedure documented in a <code class="language-plaintext highlighter-rouge">README_DFU.md</code>. The update finally worked: the sensor accepted the package, completed the transfer, and booted with the new version.</p>

<p>A detail that stung to discover: dumping the firmware with the J-Link was completely unnecessary. The binary had been there all along, inside the APK, packaged as just another app resource. We had expected the opposite: that firmware would be downloaded on demand from manufacturer servers as new releases appeared, rather than bundled inside the APK. Under that assumption, it never crossed our minds to inspect the APK’s raw resources before cracking open the sensor and wiring up the debugger. Furthermore, the APK’s <code class="language-plaintext highlighter-rouge">.zip</code> contains something our dump lacked: the init packet already signed and prepared by the manufacturer, which is precisely what the bootloader validates. The dump was valuable for understanding how the firmware was laid out, but updating the sensor only required inspecting the APK first.</p>

<figure class="align-center"><a href="/assets/images/sensor_angi/diagrama_flujo_dfu.png" class="image-popup" title="Flowchart of the ANGi sensor firmware DFU update process.
"><img src="/assets/images/sensor_angi/diagrama_flujo_dfu.png" alt="DFU process flowchart" /></a><figcaption>
      Flowchart of the ANGi sensor firmware DFU update process.

    </figcaption></figure>

<h3 id="c-integrating-dfu-into-openangi">c. Integrating DFU into OpenAngi</h3>

<p>With the update already working from the Python script, the next step was bringing it directly into the app. We asked OpenCode (using mimo-v2.6-flash-free, a free-tier model) to review the DFU folder (the README, the <code class="language-plaintext highlighter-rouge">.zip</code>, the script, and the successful update log) and add an alert and DFU update flow to OpenAngi whenever an outdated firmware is detected. The result was version detection against 2.6.13, a “Firmware (Outdated)” row on the device screen, and a series of dialogs: update required, low battery warning, progress bar, rebooting, and error with a retry button. Under the hood, it uses the Nordic DFU library, with the official embedded <code class="language-plaintext highlighter-rouge">.zip</code> and the parameters previously used by the original app.</p>

<p>The final test, verified on real hardware, was a sensor (SHS-E6F5) updated from version 2.5.22 to 2.6.13: the command was sent, the sensor confirmed, appeared as <code class="language-plaintext highlighter-rouge">DfuTarg</code>, the transfer took 47 seconds, and upon reconnection, the app read the new version and reactivated the session. That ~2 h 10 min window ran entirely on a single model without subagents: 213 assistant messages vs. 13 from us, 291 tool calls (125 bash, 61 edits, and 59 reads), ~1.05 million input tokens, 65K output tokens, and 243K reasoning tokens, at zero cost.</p>

<figure class="align-center">
  <video controls="" width="100%">
    <source src="/assets/images/sensor_angi/dfu_process.mp4" type="video/mp4" />
    Your browser does not support playing this video.
  </video>
  <figcaption>DFU update of ANGi sensor SHS-E6F5 from version 2.5.22 to 2.6.13.</figcaption>
</figure>

<hr />

<h2 id="part-5-assessment-of-ai-tool-usage">Part 5: Assessment of AI Tool Usage</h2>

<p>Each phase of this project utilized AI tools in a different way. To conclude, it is worth looking at the broader picture and drawing insights into how tasks were distributed among models and what worked best in each context.</p>

<h3 id="a-what-each-tool-did">a. What Each Tool Did</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Tool / Model</th>
      <th style="text-align: left">Primary role in the project</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Claude Sonnet 5</strong> <em>(claude.ai)</em></td>
      <td style="text-align: left">Initial BLE exploration, Python scripts, chip identification from PCB photos</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Claude Sonnet 5.5</strong> <em>(claude.ai)</em></td>
      <td style="text-align: left">Hex dump analysis, nRF52 memory map, Ghidra Java scripts</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>OpenCode</strong> (<code class="language-plaintext highlighter-rouge">mimo</code>, <code class="language-plaintext highlighter-rouge">ling</code>, <code class="language-plaintext highlighter-rouge">big-pickle</code>)</td>
      <td style="text-align: left">Android development (AngiTester and OpenAngi), decompilation and annotation of Ghidra C code</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Antigravity</strong> (<code class="language-plaintext highlighter-rouge">Claude Sonnet 4.6 Thinking</code>)</td>
      <td style="text-align: left">Reading APK to understand DFU, extracting embedded firmware, rewriting the update script</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>ChatGPT</strong> (GPT-5.6 Luna)</td>
      <td style="text-align: left">Legal framework consultation on reverse engineering in Argentina</td>
    </tr>
  </tbody>
</table>

<p>No tool replaced real fieldwork: every hypothesis had to be measured or executed directly on the sensor. What models accelerated was triaging raw binaries, writing and correcting tools, and parsing thousands of lines of decompiled Java. But they cannot touch hardware, and some of their interpretations were plain wrong.</p>

<h3 id="b-project-scale-and-cost">b. Project Scale and Cost</h3>

<p>The volume of work processed by the models was considerable. The exact numbers for OpenCode come from its SQLite database; those for Claude and Antigravity are estimates based on session logs:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Session</th>
      <th style="text-align: left">Assistant messages</th>
      <th style="text-align: left">Tool calls</th>
      <th style="text-align: left">Input tokens (uncached)</th>
      <th style="text-align: left">Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Claude Web (BLE exploration + firmware)</td>
      <td style="text-align: left">~32</td>
      <td style="text-align: left">~68</td>
      <td style="text-align: left">N/A (Web UI)</td>
      <td style="text-align: left">Subscription</td>
    </tr>
    <tr>
      <td style="text-align: left">OpenCode (Android apps + C decompilation)</td>
      <td style="text-align: left">738 + 85</td>
      <td style="text-align: left">1,067 + 209</td>
      <td style="text-align: left">~4.94 M tokens</td>
      <td style="text-align: left">$0</td>
    </tr>
    <tr>
      <td style="text-align: left">Antigravity (DFU)</td>
      <td style="text-align: left">213</td>
      <td style="text-align: left">291</td>
      <td style="text-align: left">~1.05 M tokens</td>
      <td style="text-align: left">$0</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Total</strong></td>
      <td style="text-align: left"><strong>~1,070</strong></td>
      <td style="text-align: left"><strong>~1,635</strong></td>
      <td style="text-align: left"><strong>~6 M tokens</strong></td>
      <td style="text-align: left"><strong>Minimal</strong></td>
    </tr>
  </tbody>
</table>

<p>The bulk of development—Android apps, firmware decompilation, DFU updates—ran entirely on free tiers at zero cost.</p>

<h3 id="c-observed-specialization-and-limitations">c. Observed Specialization and Limitations</h3>

<p>The choice of tool was not random; each demonstrated clear advantages depending on the task.</p>

<ul>
  <li><strong>Claude</strong> (web interface with vision) was optimal for the exploratory phase: identifying chips from photos, analyzing nRF Connect screenshots, and iteratively constructing scripts without filesystem access.</li>
  <li><strong>OpenCode</strong> (terminal with bash, file editing, and build capabilities) was the natural choice for building and debugging real applications: compiling with Gradle, fixing errors, and maintaining coherence across more than 30 Kotlin files simultaneously.</li>
  <li><strong>Antigravity</strong> (agent with filesystem access and Thinking mode) proved effective when tasks required reasoning over complex decompiled Java code and debugging undocumented protocol quirks.</li>
</ul>

<p>Every tool exhibited hallucinations or errors at some stage—detailed in <a href="#g-critical-review-of-the-c-files-and-detected-hallucinations">section g of Part 3</a> and in <a href="#f-fall-detection-algorithm">section f on the fall detection algorithm</a>. The guiding rule was consistent across the board: verify every result against real hardware or the original binary before making decisions.</p>

<p>As previously mentioned, this is also one of the lines of research we are incorporating into the Laboratory’s R&amp;D project: closing the loop—granting LLMs access to hardware and the ability to interact with physical tools—thereby eliminating the need for a user to be present during the majority of the development process.</p>

<hr />

<h2 id="conclusions-and-legal-considerations">Conclusions and Legal Considerations</h2>

<p>The entire process was influenced by the level of protection present on the device and its software. The analyzed APK did not employ obfuscation or encryption that prevented examining its logic, and the communication protocol with the ANGi could be observed and characterized without breaking cryptographic mechanisms. Furthermore, during hardware analysis we found that the nRF52 firmware was accessible under the device’s conditions, whereas it technically could have been locked down using the microcontroller’s hardware protection mechanisms (APPROTECT). All of this significantly facilitated our work and allowed us to carry out reverse engineering using relatively standard software, communication, and firmware analysis tools. It is important to emphasize that these observations describe the conditions found on the analyzed device and do not imply that the manufacturer deliberately designed the product to facilitate reverse engineering.</p>

<h3 id="how-legal-is-reverse-engineering">How Legal Is Reverse Engineering?</h3>

<p>At this point, another question arose: if the device belongs to us, what are the legal ramifications of reverse engineering it to put it back into service? For this inquiry, we consulted ChatGPT (OpenAI, GPT-5.6 Luna) regarding the legal aspects of the process, particularly within the Argentine legal framework. The general consensus was that there is an important distinction between analyzing software to extract the functional information necessary to interoperate with one’s own hardware versus copying, modifying, or redistributing the original proprietary software. In this project, the Ride APK was used strictly as an object of analysis to uncover the communication protocol, but the applications developed subsequently were written entirely from scratch and incorporate no code, resources, or layouts from the original app. Similarly, the device’s original firmware is not part of OpenAngi’s codebase; it was used solely as analysis material and, subsequently, to experimentally validate the DFU update mechanism. In fact, analyzing the firmware was not even necessary to implement OpenAngi: it served primarily as an opportunity to explore using AI tools in firmware reverse engineering.</p>

<p>This question becomes especially relevant when considering potential publication of the project. One possibility—the one presented on this page—is to publish solely the process and findings of the reverse engineering effort; another is to additionally document the discovered protocol specifications; and a third is to potentially publish the source code or an APK of an independent implementation like OpenAngi. In contrast, publishing the original APK, Ride’s decompiled code, or proprietary firmware raises distinct intellectual property concerns, and for that reason, those materials are not part of what is considered for publication. In all cases, this represents a preliminary assessment rather than formal legal advice: if the project were to move toward public or commercial distribution, specific reviews of licenses, intellectual property, and trademarks would be required.</p>

<p>There is an interesting exception within the development itself. During testing, we also implemented the ability to update the ANGi firmware via DFU. To conduct this test, we used the firmware binary file bundled inside the original APK. This allowed us to experimentally confirm the update process and verify that an independent DFU mechanism could be implemented. However, the binary used is not code we developed, but a proprietary component originating from the manufacturer’s app. For that reason, we do not consider it appropriate to redistribute it as part of an open application without prior analysis of its rights and licensing terms. The approach we are evaluating is maintaining an experimental build that includes that file solely for internal DFU testing, alongside a separate public version of OpenAngi devoid of proprietary firmware. The latter would be the candidate for any potential public distribution.</p>

<p><a href="https://hits.sh/prototipado.github.io/en/developments/specialized-angi-reverse-engineering/"><img src="https://hits.sh/prototipado.github.io/en/developments/specialized-angi-reverse-engineering/.svg" alt="Hits" /></a></p>]]></content><author><name>Juan I. Cerrudo</name><email>juan.cerrudo@uner.edu.ar</email></author><category term="Developments" /><category term="Hardware" /><category term="BLE" /><category term="Android" /><category term="Kotlin" /><category term="nRF52" /><category term="Artificial Intelligence" /><summary type="html"><![CDATA[Reverse engineering the Specialized ANGi sensor: reconstructing its BLE protocol, building Android apps, analyzing nRF52 firmware, and characterizing power consumption with AI tools.]]></summary></entry><entry xml:lang="en"><title type="html">ESP32: Does Module Placement Matter?</title><link href="https://prototipado.github.io/en/experiments/esp32-antenna-placement/" rel="alternate" type="text/html" title="ESP32: Does Module Placement Matter?" /><published>2026-08-18T00:00:00+00:00</published><updated>2026-08-18T00:00:00+00:00</updated><id>https://prototipado.github.io/experiments/esp32_antena-en</id><content type="html" xml:base="https://prototipado.github.io/experiments/esp32-antenna-placement/"><![CDATA[<h2 id="introduction">Introduction</h2>

<p>At the Laboratory, we have worked on a range of projects using wireless modules, including different ESP32 variants and Nordic’s nRF52840. We usually follow the manufacturers’ design guidelines, which typically recommend placing a module near the edge of the board and leaving a clear area around the antenna to optimize its performance.
However, we have seen projects ignore these guidelines more than once, which made us wonder how much of this guidance is a recommendation and how much is a requirement.</p>

<figure class="align-center"><a href="/assets/images/esp32_antena/esp32_2.png" class="image-popup" title="Examples of ESP32 module placement on PCBs that do not follow the manufacturer’s guidelines.
"><img src="/assets/images/esp32_antena/esp32_2.png" alt="Examples of ESP32 PCB footprints" /></a><figcaption>
      Examples of ESP32 module placement on PCBs that do not follow the manufacturer’s guidelines.

    </figcaption></figure>

<p>Espressif explains how to position a module with a PCB antenna in its <a href="https://docs.espressif.com/projects/esp-hardware-design-guidelines/en/latest/esp32/pcb-layout-design.html">Hardware Design Guidelines</a>.</p>

<p>Still, we wanted to find out how much the signal from an ESP32 with an on-board antenna changes depending on where it is placed. Would the difference be just a few decibels, or could it make communication impossible?</p>

<h2 id="the-experiment">The Experiment</h2>

<p>To run a series of experiments, we built a test base: a single-sided PCB placed on top of another board to simulate a double-sided PCB with a continuous ground plane. It had six possible mounting positions for the same physical module. We did not use six different modules; we repeatedly moved the same one, holding it in place with soldered pins that provided both mechanical support and an electrical connection to the ground plane. For the center position, we removed the copper from the area that would be the antenna keepout beneath the antenna, as the manufacturer recommends. This area is usually defined in the module footprint, so that particular recommendation is followed even when the placement recommendation is not.</p>

<figure class="align-center"><a href="/assets/images/esp32_antena/placa.jpg" class="image-popup" title="Test base board with six possible mounting positions for the ESP32 module. The positions are numbered 1 to 6.
"><img src="/assets/images/esp32_antena/placa.jpg" alt="Test board with six positions" /></a><figcaption>
      Test base board with six possible mounting positions for the ESP32 module. The positions are numbered 1 to 6.

    </figcaption></figure>

<p>The board connects to a laptop running an app that sends commands over a serial port to configure experiments and collect data.</p>

<figure class="align-center"><a href="/assets/images/esp32_antena/app.png" class="image-popup" title="The app connects to both devices, configures them, receives data, plots RSSI and throughput in real time, and saves each session’s data to a CSV file.
"><img src="/assets/images/esp32_antena/app.png" alt="Experiment configuration app" /></a><figcaption>
      The app connects to both devices, configures them, receives data, plots RSSI and throughput in real time, and saves each session’s data to a CSV file.

    </figcaption></figure>

<p>The app also communicates with a second ESP32 board that remains fixed in one position. We measured RSSI and throughput using three different protocols (ESP-NOW, BLE, and Bluetooth Classic), initially sending batches of 200 100-byte packets every 100 ms per run, for a low input rate of 8 kbps that would not saturate the receiving link. In total, we collected 70 sessions and about 800 RSSI samples per position and protocol.</p>

<figure class="align-center"><a href="/assets/images/esp32_antena/setup.jpg" class="image-popup" title="Measurement setup with the base board, laptop, and second ESP32 board, 230 cm apart on a wooden table. The ESP32 module was moved among the six possible positions for each session.
"><img src="/assets/images/esp32_antena/setup.jpg" alt="Measurement setup" /></a><figcaption>
      Measurement setup with the base board, laptop, and second ESP32 board, 230 cm apart on a wooden table. The ESP32 module was moved among the six possible positions for each session.

    </figcaption></figure>

<h2 id="result-1-footprint-position-matters-a-lot">Result 1: Footprint Position Matters, A Lot</h2>

<p>The results table makes it quite clear:</p>

<table>
  <thead>
    <tr>
      <th>Protocol</th>
      <th>Best position</th>
      <th>Worst position</th>
      <th>Gap (best − worst)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ESP-NOW</td>
      <td>3 (-43.31 dBm)</td>
      <td>5, center (-54.48 dBm)</td>
      <td><strong>11.17 dB</strong></td>
    </tr>
    <tr>
      <td>BLE</td>
      <td>3 (-58.21 dBm)</td>
      <td>5, center (-69.77 dBm)</td>
      <td><strong>11.56 dB</strong></td>
    </tr>
    <tr>
      <td>Bluetooth Classic</td>
      <td>3 (-16.00, relative scale)</td>
      <td>5, center (-27.90)</td>
      <td><strong>11.90 dB</strong></td>
    </tr>
  </tbody>
</table>

<p>That is an 11 to 12 dB difference just from moving the chip a few centimeters on the same board. Same module, same distance, same antenna. Losing 6 dB means receiving half the power. Here, we lost almost twice that just by choosing a poor location.</p>

<h2 id="result-2-the-ranking-is-consistent-across-all-three-protocols">Result 2: The Ranking Is Consistent Across All Three Protocols</h2>

<p>The best and worst locations were exactly the same for ESP-NOW, BLE, and Bluetooth Classic. Three protocols with different radio stacks operating at 2.4 GHz, and they all agreed:</p>

<table>
  <thead>
    <tr>
      <th>Protocol</th>
      <th>Complete RSSI ranking (best → worst)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>BLE</strong></td>
      <td>3 (-58.21 dBm) &gt; 1 (-66.01) &gt; 6 (-67.40) &gt; 2 (-68.38) &gt; 4 (-68.39) &gt; 5 (-69.77)</td>
    </tr>
    <tr>
      <td><strong>Bluetooth Classic</strong></td>
      <td>3 (-16.00) &gt; 1 (-23.95) &gt; 4 (-25.72) &gt; 6 (-26.33) &gt; 2 (-26.86) &gt; 5 (-27.90)</td>
    </tr>
    <tr>
      <td><strong>ESP-NOW</strong></td>
      <td>3 (-43.31 dBm) &gt; 1 (-47.44) &gt; 4 (-50.86) &gt; 6 (-53.48) &gt; 2 (-54.45) &gt; 5 (-54.48)</td>
    </tr>
  </tbody>
</table>

<p>Position 3 was the clear winner: it was best every time, across all three protocols, leading the runner-up by 4.1 to 7.9 dB. Position 1 was consistently second. Positions 4, 6, and 2 clustered in the middle, very close to one another. Position 5, in the center, was by far the worst (or tied for last in ESP-NOW).</p>

<h2 id="why-position-3-wins-and-position-5-loses-the-rf-explanation">Why Position 3 Wins and Position 5 Loses: The RF Explanation</h2>

<p>This fits perfectly with Espressif’s guidance. The antenna needs a copper-free clearance area around it (no ground planes or nearby metal components) to avoid detuning, and the feed should point toward a free edge of the board.</p>

<p>Position 3 is right at a corner, pointing outward with nothing nearby to block it. Position 5 is the opposite: it is enclosed by the other positions and surrounded by copper on all four sides, a textbook example of antenna detuning caused by insufficient keepout.</p>

<p>To visualize the results more intuitively than with a box plot, we wrote a script that generates “long exposure with synthetic LEDs” images: it overlays colored points of light on the board photo, one for each RSSI sample, with a soft Gaussian glow and a screen blend (light accumulates and brightens without obscuring what is underneath). The result looks more like an actual long-exposure photograph than a chart, and makes it easy to see the signal level at each position and how much it varies.</p>

<figure class="align-center"><a href="/assets/images/esp32_antena/led_glow_rssi_BLE.png" class="image-popup" title="RSSI measured at each position on the base board. The color and density of the lights encode signal strength (red = worse, green = better).
"><img src="/assets/images/esp32_antena/led_glow_rssi_BLE.png" alt="BLE LED-glow RSSI visualization" /></a><figcaption>
      RSSI measured at each position on the base board. The color and density of the lights encode signal strength (red = worse, green = better).

    </figcaption></figure>

<figure class="align-center"><a href="/assets/images/esp32_antena/led_glow_rssi_BT_CLASSIC.png" class="image-popup" title="RSSI measured at each position on the base board. The color and density of the lights encode signal strength (red = worse, green = better).
"><img src="/assets/images/esp32_antena/led_glow_rssi_BT_CLASSIC.png" alt="Bluetooth Classic LED-glow RSSI visualization" /></a><figcaption>
      RSSI measured at each position on the base board. The color and density of the lights encode signal strength (red = worse, green = better).

    </figcaption></figure>

<figure class="align-center"><a href="/assets/images/esp32_antena/led_glow_rssi_ESPNOW.png" class="image-popup" title="RSSI measured at each position on the base board. The color and density of the lights encode signal strength (red = worse, green = better).
"><img src="/assets/images/esp32_antena/led_glow_rssi_ESPNOW.png" alt="ESP-NOW LED-glow RSSI visualization" /></a><figcaption>
      RSSI measured at each position on the base board. The color and density of the lights encode signal strength (red = worse, green = better).

    </figcaption></figure>

<h2 id="a-result-that-does-not-quite-add-up-positions-3-and-6">A Result That Does Not Quite Add Up: Positions 3 and 6</h2>

<p>According to the manufacturer’s guidelines, positions 3 and 6 should both be the best: in both, the antenna extends beyond the base board and the feed point is near the edge, exactly as Espressif recommends. Position 3 always performed as expected, but position 6 did not do as well. In one run, position 4 beat position 6; in the next, the order was reversed. Positions 3 and 5 were always very stable, but position 6 showed considerable variation between repetitions.</p>

<p>We do not have a definitive answer yet, but we have a few hypotheses. Since we removed and repositioned the module by hand for each session, a tiny change in its resting angle may have affected the measurement.
Also, at 2.4 GHz, walls and objects in the environment create reflections (multipath) that change with millimeter-scale differences in distance; the test environment may have disadvantaged that particular position.</p>

<h2 id="what-the-throughput-did-not-show-in-the-first-run">What the Throughput Did NOT Show in the First Run</h2>

<p>At an input rate of 8 kbps, the transfer speed was exactly the same for every position and protocol: a steady 8.00 kbps.</p>

<p>That does not mean module placement has no effect on speed. At this low rate, the link operates very comfortably even in the worst case: at -70 dBm, it is still nowhere near the sensitivity limit of these chips. We needed to push the test to its limit.</p>

<h2 id="second-run-increasing-the-transfer-rate">Second Run: Increasing the Transfer Rate</h2>

<p>We ran a second set of tests. Same positions, same distance, same three protocols, but this time we pushed the network with larger packets and tighter timing. We brought ESP-NOW to about 160 kbps, BLE to 200 kbps, and Bluetooth Classic to 410 kbps (20 to 50 times faster than before).</p>

<p>RSSI again showed the same marked difference:</p>

<table>
  <thead>
    <tr>
      <th>Protocol</th>
      <th>Best position</th>
      <th>Worst position</th>
      <th>Gap (best − worst)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ESP-NOW</td>
      <td>3 (-40.7 dBm)</td>
      <td>1 (-53.7 dBm)</td>
      <td><strong>13.0 dB</strong></td>
    </tr>
    <tr>
      <td>BLE</td>
      <td>3 (-58.6 dBm)</td>
      <td>5, center (-72.0 dBm)</td>
      <td><strong>13.4 dB</strong></td>
    </tr>
    <tr>
      <td>Bluetooth Classic</td>
      <td>3 (-16.0, relative)</td>
      <td>4 (-30.8)</td>
      <td><strong>14.8 dB</strong></td>
    </tr>
  </tbody>
</table>

<p>The ranking shifted a little. Position 6 moved up:</p>

<table>
  <thead>
    <tr>
      <th>Protocol</th>
      <th>Ranking (best → worst)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ESP-NOW</td>
      <td>3 &gt; 6 &gt; 5 (center) &gt; 2 &gt; 4 &gt; 1</td>
    </tr>
    <tr>
      <td>BLE</td>
      <td>3 &gt; 6 &gt; 2 &gt; 1 &gt; 4 &gt; 5 (center)</td>
    </tr>
    <tr>
      <td>Bluetooth Classic</td>
      <td>3 &gt; 6 &gt; 1 &gt; 2 &gt; 5 (center) &gt; 4</td>
    </tr>
  </tbody>
</table>

<p>Position 3 was once again the clear winner, leading in both runs and every measurement. Position 6 came second in all three protocols, which was not as clear in the first run and now agrees with Espressif’s recommendation (feed facing outward). The other middle positions shifted somewhat; differences in that middle group are small and very sensitive to the environment.</p>

<p>And the most curious result of the second run: despite pushing the network hard, throughput barely noticed. We saw isolated performance drops during bursts of lost packets, but they were scattered equally across all positions. The signal has to degrade much further for throughput to drop systematically. <strong>RSSI exposes poor module placement long before you notice it in the transfer speed.</strong></p>

<h2 id="transmitterreceiver-swap-bidirectional-link-a--b-vs-b--a">Transmitter/Receiver Swap: Bidirectional Link (A → B vs. B → A)</h2>

<p>For this third round, we increased the distance between the boards to 350 cm (up from 230 cm) and swapped transmitter and receiver halfway through each test. We wanted to see whether the outbound link (A → B) and return link (B → A) produced symmetric RSSI and throughput, or whether link direction introduced a difference.</p>

<h3 id="position-ranking-at-350-cm">Position Ranking at 350 cm</h3>

<p>At the greater distance, the gap between the best and worst positions remained wide (between 14.4 and 15.8 dB):</p>

<table>
  <thead>
    <tr>
      <th>Protocol</th>
      <th>Best position</th>
      <th>Worst position</th>
      <th>Gap (best − worst)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ESP-NOW</td>
      <td>3 (-49.33 dBm)</td>
      <td>6 (-63.74 dBm)</td>
      <td><strong>14.41 dB</strong></td>
    </tr>
    <tr>
      <td>BLE</td>
      <td>3 (-65.40 dBm)</td>
      <td>5, center (-81.17 dBm)</td>
      <td><strong>15.78 dB</strong></td>
    </tr>
    <tr>
      <td>Bluetooth Classic</td>
      <td>3 (-23.88, relative)</td>
      <td>5, center (-38.54)</td>
      <td><strong>14.66 dB</strong></td>
    </tr>
  </tbody>
</table>

<p>The ranking shifted a little compared with the previous runs:</p>

<table>
  <thead>
    <tr>
      <th>Protocol</th>
      <th>Complete RSSI ranking (best → worst)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>BLE</strong></td>
      <td>3 (-65.40 dBm) &gt; 2 (-68.08) &gt; 4 (-69.07) &gt; 1 (-72.96) &gt; 6 (-73.72) &gt; 5 (-81.17)</td>
    </tr>
    <tr>
      <td><strong>Bluetooth Classic</strong></td>
      <td>3 (-23.88) &gt; 2 (-26.10) &gt; 4 (-27.08) &gt; 1 (-31.22) &gt; 6 (-31.26) &gt; 5 (-38.54)</td>
    </tr>
    <tr>
      <td><strong>ESP-NOW</strong></td>
      <td>3 (-49.33 dBm) &gt; 2 (-51.67) &gt; 4 (-53.99) &gt; 5 (-55.55) &gt; 1 (-56.89) &gt; 6 (-63.74)</td>
    </tr>
  </tbody>
</table>

<p>Position 3 was again first in every case. This time position 2 moved up to second place and position 4 to third, with similar results across all three modes. Position 5 (center) remained last in BLE and Bluetooth Classic, dropping to -81 dBm in BLE. In ESP-NOW, position 6 fell to last place, probably because that orientation is more affected at 3.5 meters.</p>

<figure class="align-center"><a href="/assets/images/esp32_antena/led_glow_rssi_direccion_BLE.png" class="image-popup" title="RSSI by position and link direction (BLE). Each module is split into A→B (amber) and B→A (cyan). Directional asymmetry is milder and less consistent than in ESP-NOW, and is not statistically significant for half the positions.
"><img src="/assets/images/esp32_antena/led_glow_rssi_direccion_BLE.png" alt="BLE RSSI by position and link direction" /></a><figcaption>
      RSSI by position and link direction (BLE). Each module is split into A→B (amber) and B→A (cyan). Directional asymmetry is milder and less consistent than in ESP-NOW, and is not statistically significant for half the positions.

    </figcaption></figure>

<figure class="align-center"><a href="/assets/images/esp32_antena/led_glow_rssi_direccion_BT_CLASSIC.png" class="image-popup" title="RSSI by position (Bluetooth Classic). Each module shows a single cluster: the firmware can read RSSI from only one side of the link at a time, so no A→B vs. B→A comparison is available in this mode. The color indicates which direction the one real measurement came from.
"><img src="/assets/images/esp32_antena/led_glow_rssi_direccion_BT_CLASSIC.png" alt="Bluetooth Classic RSSI by position" /></a><figcaption>
      RSSI by position (Bluetooth Classic). Each module shows a single cluster: the firmware can read RSSI from only one side of the link at a time, so no A→B vs. B→A comparison is available in this mode. The color indicates which direction the one real measurement came from.

    </figcaption></figure>

<figure class="align-center"><a href="/assets/images/esp32_antena/led_glow_rssi_direccion_ESPNOW.png" class="image-popup" title="RSSI by position and link direction (ESP-NOW). Each module is split into A→B (amber) and B→A (cyan). At most positions, B→A is consistently 1 to 2 dB better than A→B—a hardware asymmetry between boards, not the channel—so the two directions should not be averaged without distinguishing them.
"><img src="/assets/images/esp32_antena/led_glow_rssi_direccion_ESPNOW.png" alt="ESP-NOW RSSI by position and link direction" /></a><figcaption>
      RSSI by position and link direction (ESP-NOW). Each module is split into A→B (amber) and B→A (cyan). At most positions, B→A is consistently 1 to 2 dB better than A→B—a hardware asymmetry between boards, not the channel—so the two directions should not be averaged without distinguishing them.

    </figcaption></figure>

<h3 id="throughput-and-link-symmetry">Throughput and Link Symmetry</h3>

<p>Throughput was nearly identical in both directions (A → B vs. B → A):</p>

<ul>
  <li>BLE: 7.80 kbps in A → B vs. 7.91 kbps in B → A (p = 0.2494, not significant).</li>
  <li>Bluetooth Classic: 7.87 kbps in A → B vs. 7.89 kbps in B → A (p = 0.7621, not significant).</li>
  <li>ESP-NOW: 7.84 kbps in A → B vs. 7.89 kbps in B → A (p = 0.5612, not significant).</li>
</ul>

<p>Link direction can introduce small RSSI asymmetries, on the order of 0.3 to 1.7 dB, probably due to minor differences in sensitivity or transmission power between the two specific modules we used. But throughput is unaffected, and module placement on the PCB remains what really matters.</p>

<h2 id="near-field-visualization">Near-Field Visualization</h2>

<p>The synthetic visualizations were useful, but they made us want to try the real thing: mount a physical WS2812B LED on the board, move it through space in a dark room, and capture its path with a camera. We did—or at least tried.</p>

<p>At first, we tried moving the board with the LED by hand over the test board. We could see color changes, but the path was not very even, so no clean pattern emerged.</p>

<figure class="half">
  <a href="/assets/images/esp32_antena/DSC_0011.JPG"><img src="/assets/images/esp32_antena/DSC_0011.JPG" alt="Manual movement above the antenna" /></a>
  <a href="/assets/images/esp32_antena/DSC_0014.JPG"><img src="/assets/images/esp32_antena/DSC_0014.JPG" alt="Manual movement above the antenna" /></a>
  <figcaption>The concentration of green near the module is visible, gradually shifting to red to represent worse RSSI farther away.</figcaption>
</figure>

<p>We then used a 3D printer (an Artillery Sidewinder X1). A Python script generated G-code for a systematic path in the XZ plane. We mounted the board on the extruder and let the machine sweep the module through different positions. The results:</p>

<figure class="half">
  <a href="/assets/images/esp32_antena/ble_pos_3.JPG"><img src="/assets/images/esp32_antena/ble_pos_3.JPG" alt="BLE at position 3" /></a>
  <a href="/assets/images/esp32_antena/ble_pos_5.JPG"><img src="/assets/images/esp32_antena/ble_pos_5.JPG" alt="BLE at position 5" /></a>
  <figcaption>BLE comparison between position 3 (best) and position 5 (worst).</figcaption>
</figure>

<p>There is significantly more “red” at position 5, indicating poor signal reception.</p>

<h2 id="pcb-design-takeaways">PCB Design Takeaways</h2>

<p>Moving the module a few centimeters on the same board changes RSSI by 11 to 15 dB. Not all corners are equal.</p>

<p>Surrounding the antenna with a ground plane is the worst thing you can do. The module in the center, surrounded by copper, produced the worst results in almost every test. Pointing the feed toward a free edge is the safe approach: position 3 (corner with a clear path outward) won all six tests, and position 6 (same principle: antenna outside the board and feed point at the edge) also performed very well in the fast run. The manufacturer’s theory holds up.</p>

<p>That said, losing 11 dB is not the same as losing the connection. In every test, even position 5 (the worst) kept the link active, and throughput did not collapse until we forced very high rates. If a device operates over short distances, in an environment with good propagation, or with a generous link budget, a suboptimal location may be perfectly acceptable. The problem arises when the signal budget is tight: greater distance, walls, interference, or low-power modules. In those situations, the 11 dB “given away” by poor placement can be the difference between a stable link and one that drops out.</p>

<p>If you only measure transfer speed, you will not notice any difference at short range, even when the signal is 12 dB lower. RSSI is the only metric that immediately tells you whether the location is poor. And watch out for the middle positions: the first and last places were clear, but the ones in the middle shifted somewhat from test to test. If you are making a design decision based on a middle position, measure it a couple more times.</p>

<hr />

<p><em>Note on Bluetooth Classic RSSI: in this mode, the firmware does not read an absolute RSSI value in dBm as it does with Wi-Fi/ESP-NOW or BLE. The ESP32 Bluetooth Classic controller only exposes <code class="language-plaintext highlighter-rouge">esp_bt_gap_read_rssi_delta()</code>, which returns a value relative to the “Golden Receive Power Range”—the power range the chip itself considers ideal for its internal power-control algorithm. A value of 0 means “the signal is within the ideal range,” a negative value means “weaker than ideal,” and a positive value means “stronger than ideal.” It is not an absolute power measurement and cannot be directly compared in dB with Wi-Fi or BLE RSSI. That is why the Bluetooth Classic graphs use a relative scale (for example, -16 to -28) instead of typical dBm values: they do not represent the same physical quantity, and their values should not be compared number for number with the other two protocols. Only the trend between positions within Bluetooth Classic itself should be compared.</em></p>

<hr />

<p><a href="https://hits.sh/prototipado.github.io/en/experiments/esp32-antenna-placement/"><img src="https://hits.sh/prototipado.github.io/en/experiments/esp32-antenna-placement/.svg" alt="Hits" /></a></p>]]></content><author><name>Juan I. Cerrudo</name><email>juan.cerrudo@uner.edu.ar</email></author><category term="Experiments" /><category term="ESP32" /><category term="RF" /><category term="Antenna" /><category term="PCB" /><summary type="html"><![CDATA[A comparative experiment on how ESP32 module placement affects RF performance: RSSI and throughput over ESP-NOW, BLE, and Bluetooth Classic in six mounting positions on the same PCB.]]></summary></entry><entry xml:lang="en"><title type="html">From Real ECG to a Virtual Heart: Building an ECG Generator from Scratch</title><link href="https://prototipado.github.io/en/developments/ecg-signal-generator/" rel="alternate" type="text/html" title="From Real ECG to a Virtual Heart: Building an ECG Generator from Scratch" /><published>2026-08-18T00:00:00+00:00</published><updated>2026-08-18T00:00:00+00:00</updated><id>https://prototipado.github.io/developments/generador_ecg-en</id><content type="html" xml:base="https://prototipado.github.io/developments/ecg-signal-generator/"><![CDATA[<h2 id="introduction">Introduction</h2>

<p>In mid-2024, the idea of developing an ECG signal generator came up at the Laboratory. Eduardo Filomena, one of the Laboratory’s members, proposed it with a clear goal: to have a tool that could test electrocardiography equipment while also serving educational and demonstration purposes.</p>

<p>By that time, we had already been working for a while on a 12-lead wireless electrocardiograph. As part of that work, we needed to test the device and, until then, we usually used a function generator that included some biological signals. The problem was that this equipment was limited to a single ECG channel.</p>

<p>A generator capable of producing multiple leads and allowing us to customize the generated signals was especially appealing. It would let us run more complete tests on the electrocardiograph, generate different conditions and signals to evaluate its behavior, and potentially automate test sessions.</p>

<p>That need gave rise to the project documented in this series of posts. We intend to retrace the path we followed during development: from recreating the original design, started by Eduardo Filomena, through the tests and problems we encountered, to the changes and improvements we eventually incorporated.</p>

<p>The starting point was a <a href="https://www.raspberrypi.com/products/raspberry-pi-pico//">Raspberry Pi Pico</a> (RP2040) programmed in Arduino. The microcontroller stored the signal data and used it to generate the signals for the 10 conventional ECG leads through PWM.
Since the microcontroller’s output was a PWM signal, it had to be conditioned to obtain an analog signal. The outputs passed through a series of passive low-pass filters, which attenuated the PWM’s high-frequency component and reconstructed the ECG waveform. A resistive divider then attenuated the amplitude to millivolt levels.</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/primer_prototipo.jpeg" class="image-popup" title="First ECG generator prototype.
"><img src="/assets/images/generador_ecg/primer_prototipo.jpeg" alt="First ECG generator prototype" /></a><figcaption>
      First ECG generator prototype.

    </figcaption></figure>

<p>Our first step was to recreate the device. Before modifying it or thinking about a new version, we wanted to understand how it was built, how the signals were generated, and, above all, check how well it worked in practice.</p>

<p align="center">
  <video controls="" width="80%">
    <source src="/assets/images/generador_ecg/ecg_3.mp4" type="video/mp4" />
  </video>
</p>

<p>That first prototype proved useful for the initial signal-acquisition tests with the electrocardiograph prototype we were developing.</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/setup.jpg" class="image-popup" title="Test setup with the device and the electrocardiograph under development.
"><img src="/assets/images/generador_ecg/setup.jpg" alt="Test setup with the device and the electrocardiograph under development" /></a><figcaption>
      Test setup with the device and the electrocardiograph under development.

    </figcaption></figure>

<p>Immediately after recreating the original device, we began improving its capabilities.
One of the first changes was migrating the code to C. From then on, we carried out all subsequent development in Visual Studio Code using the official Raspberry Pi SDK for the Pico. As is increasingly common in our projects, various artificial intelligence tools assisted a significant part of the development and programming work.</p>

<p>But we did not want to stop at a software improvement. As we began using the generator, new needs emerged.</p>

<p>We thought it would be much more useful to have a device that could be carried easily and used independently, without relying on a computer during tests. So we decided to add an 18650 LiPo battery as a power source, along with a charging-management board. Besides making it portable, this offered another benefit for our use case: it allowed us to work with a floating ground reference relative to other equipment during testing.</p>

<p>We also added our own user interface, based on an OLED display and a rotary encoder. This let us select signals and configure the generator directly on the device, without connecting it to a PC.</p>

<p>We developed the enclosure in Fusion 360, going through several versions.</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/evol.png" class="image-popup" title="Evolution of the ECG generator.
"><img src="/assets/images/generador_ecg/evol.png" alt="Evolution of the ECG generator" /></a><figcaption>
      Evolution of the ECG generator.

    </figcaption></figure>

<h2 id="electronic-design">Electronic Design</h2>

<p>The circuit design also went through several stages. As development progressed and we gained a better understanding of what we needed from the device, we changed both the electronics and the signal-conditioning approach. First, we switched to a <a href="https://www.raspberrypi.com/products/raspberry-pi-pico-2/">Raspberry Pi Pico 2</a> (RP2350) to improve the device’s performance. Although the microcontroller was not the system’s bottleneck, it gave us more memory and a faster processor.</p>

<p align="center">
  <video controls="" width="80%">
    <source src="/assets/images/generador_ecg/final_2.mp4" type="video/mp4" />
  </video>
</p>

<p>The final implementation uses active low-pass filters, specifically Sallen–Key filters, designed to reconstruct analog signals from the Raspberry Pi Pico’s PWM outputs. The design was heavily influenced by the components we already had available in the laboratory. The op-amps are Texas Instruments <a href="https://www.ti.com/product/OPA2335">OPA2335</a>, whose main feature of interest to us was their rail-to-rail operation, meaning they can handle input and output voltages very close to the supply rails. By looking at the passive components we had on hand, we found a combination that gave us a 256 Hz cutoff frequency, appropriate for the application.</p>

<p>Before building the circuit on a board, we ran several simulations to check its behavior and adjust the components. This allowed us to evaluate the frequency response, attenuation of the PWM’s high-frequency component, and the resulting output waveform.</p>

<h2 id="simulation">Simulation</h2>

<p>As shown in the image, we ran the simulation using <a href="https://www.analog.com/en/resources/design-tools-and-calculators/ltspice-simulator.html">LTspice</a>, a free electronic-circuit simulator developed by Analog Devices.</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/sim.png" class="image-popup" title="Active low-pass filter simulation.
"><img src="/assets/images/generador_ecg/sim.png" alt="Active low-pass filter simulation" /></a><figcaption>
      Active low-pass filter simulation.

    </figcaption></figure>

<p>The filter outputs pass through a resistive divider that reduces the signal. A second op-amp, configured as a voltage follower, isolates the divider from a 470-ohm output resistor, intended to simulate tissue impedance.</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/Figure_1.png" class="image-popup" title="Transient simulation results, from top to bottom: ECG output after the resistive divider, original signal, filtered PWM signal, and unfiltered PWM signal.
"><img src="/assets/images/generador_ecg/Figure_1.png" alt="Active low-pass filter simulation results" /></a><figcaption>
      Transient simulation results, from top to bottom: ECG output after the resistive divider, original signal, filtered PWM signal, and unfiltered PWM signal.

    </figcaption></figure>

<p>Around the same time, we were about to start a new project at the Laboratory: developing an external pacemaker.
This led us to add a feature that was not necessary for the ECG generator itself but could be very useful in the future. We incorporated three input circuits connected to the Raspberry Pi Pico’s ADC to detect pacemaker pulses.</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/pace_sim.png" class="image-popup" title="Input circuit for pacemaker evaluation.
"><img src="/assets/images/generador_ecg/pace_sim.png" alt="Input circuit for pacemaker evaluation" /></a><figcaption>
      Input circuit for pacemaker evaluation.

    </figcaption></figure>

<p>The idea behind this addition was to go beyond simply generating ECG signals. In the future, we wanted to use this hardware as part of a system that could simulate the interaction between a heart and a pacemaker, eventually moving toward an in-silico heart model.
At the time, this was still a future idea, but it seemed worthwhile to prepare the platform so the same device could be part of that development.</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/final_1.jpg" class="image-popup" title="Final ECG generator prototype. Sections of transparent PETG filament can be seen, used as light pipes to guide LED light to the outside of the enclosure.
"><img src="/assets/images/generador_ecg/final_1.jpg" alt="Final prototype" /></a><figcaption>
      Final ECG generator prototype. Sections of transparent PETG filament can be seen, used as light pipes to guide LED light to the outside of the enclosure.

    </figcaption></figure>

<p align="center">
  <video controls="" width="80%">
    <source src="/assets/images/generador_ecg/final_3.mp4" type="video/mp4" />
  </video>
</p>

<h2 id="signal-generation-and-processing">Signal Generation and Processing</h2>

<p>Another improvement we made was to expand the range of signals the device could generate. In addition to ECG signals, we added basic test signals—sine, square, and triangle waves—with controls to change their amplitude, offset, and frequency directly.
These signals were especially useful during development because they let us test each stage of the system with known, easily parameterized waveforms. This allowed us to verify PWM generation, filter performance, and output behavior separately before moving on to more complex signals.</p>

<p>The most interesting part, however, came when we started working with real ECG signals.
For this, we used the <a href="https://www.physionet.org/content/ludb/1.0.1/">Lobachevsky University Electrocardiography Database (LUDB)</a>, which contains 12-lead ECG recordings. We developed a Python notebook to take these recordings and transform them into data that could be used directly by the Raspberry Pi Pico firmware.
The first step is to read a recording and obtain its 12 leads. We detect the peaks corresponding to QRS complexes in one of them, then use those peaks as references to identify the cardiac cycles present in the recording.</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/jupyter_1.png" class="image-popup" title="Example ECG signal from the LUDB database.
"><img src="/assets/images/generador_ecg/jupyter_1.png" alt="Example ECG signal from the LUDB database" /></a><figcaption>
      Example ECG signal from the LUDB database.

    </figcaption></figure>

<p>Rather than simply selecting any heartbeat, we looked for one that could be repeated continuously. For each candidate cycle, we calculated the difference between its starting and ending values across all leads and selected the one with the smallest difference. This gives us a heartbeat that transitions as smoothly as possible when it starts again after completing a cycle.</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/jupyter_2.png" class="image-popup" title="ECG cycle selected for generation.
"><img src="/assets/images/generador_ecg/jupyter_2.png" alt="ECG cycle selected for generation" /></a><figcaption>
      ECG cycle selected for generation.

    </figcaption></figure>

<p>Once a cycle is selected, we extract the samples for the nine leads used by the generator and scale them appropriately for representation as integer values. In our case, the data are rescaled using the operation 2000 + 30000, centering them around the value 30000.</p>

<p>This midpoint offset reflects how the Raspberry Pi Pico works: powered by a single supply (0 V to 3.3 V) and using a 16-bit PWM resolution (0 to 65535), the value 30000 establishes a DC level, or virtual ground, of approximately 1.51 V (45.7% duty cycle). This leaves enough headroom to represent both positive ECG deflections (P, R, and T waves) and negative ones (Q and S waves) without clipping at 0 V.</p>

<p>Finally, all samples are converted to integers and automatically packed into a <code class="language-plaintext highlighter-rouge">.h</code> header file. The file contains an array of heartbeat samples along with a structure holding additional recording information, such as rhythm and signal-related characteristics.
The result is a real ECG heartbeat converted into a format the firmware can use directly. The Raspberry Pi Pico does not need to process the original signal or access the database: the samples for the cycle are simply stored in memory and repeatedly played through the PWM outputs.
This processing also gave us something we had wanted from the beginning: the ability to add new signals to the generator without manually changing the firmware. Given a database recording, the notebook selects and prepares the heartbeat and generates the file that we later add to the project.
The generator now has two broad groups of signals: synthetic, parameterizable signals for hardware testing, and real physiological signals, processed and prepared for playback on the device.</p>

<h2 id="communication-and-cli">Communication and CLI</h2>

<p>To make testing easier to automate, we also decided to add a CLI to the device, allowing us to change its behavior with commands—for example, the signal type (synthetic or real) and its parameters.
We then created a small Python app with a graphical interface to control the device more conveniently. Another important feature of this app is that it lets us view the generated signals in real time. In the future, this could let us compare the generated signal with a hypothetical acquired signal and evaluate the acquisition system’s performance—a way to “close the loop.”</p>

<figure class="align-center"><a href="/assets/images/generador_ecg/gui.png" class="image-popup" title="Graphical interface for the ECG generator control software.
"><img src="/assets/images/generador_ecg/gui.png" alt="Graphical interface for the ECG generator control software" /></a><figcaption>
      Graphical interface for the ECG generator control software.

    </figcaption></figure>

<h2 id="a-limitation-and-the-next-step">A Limitation and the Next Step</h2>

<p>Despite all these improvements, the generator still had an important limitation: the signals it could play were predefined and stored in memory. We could choose among different recordings and play real signals, but we had no easy way to change their morphology or heart rate in real time.
This became increasingly apparent as we used the device for testing. If we wanted to change the heart rate, modify a morphological feature, or generate a particular condition, we had to prepare a new signal in advance and add it to the firmware again.</p>

<p>The next question came naturally: could we generate the ECG in real time instead of limiting ourselves to replaying previously stored signals?
Starting from this question, in upcoming posts we will explore mathematical models for ECG signal generation. The idea is to move from a system based mainly on replaying stored recordings to one where we can control signal characteristics much more precisely, changing them in real time to suit the test.
This opens a new stage of the project: we are no longer only interested in replaying an ECG, but also in understanding how to model and generate one.</p>

<p>There was another limitation, and it was not purely technical. Repeating the same recording indefinitely also meant losing some of the dynamics of a real heart. Every cycle was exactly like the last: the RR interval repeated, the waveform morphology stayed constant, and there was no natural variation in heart rate. There was no baseline drift or small beat-to-beat variation in the amplitude and duration of the different components either. In other words, we could reproduce a particular signal very well, but we were not reproducing the dynamic behavior behind it.</p>

<p>This became especially clear when we thought about what we wanted the generator to do. It was not enough to have an ECG that “looked like” a real recording; we wanted to change its heart rate, introduce variability, alter its morphology, and simulate different conditions without finding and storing a new recording for every case. The first version was still very useful for testing the electronics and verifying real-signal acquisition, but it had a clear limit: we were not simulating a heart; we were replaying a recording of one. That difference is what led us to look for a way to generate ECG signals from a mathematical model.</p>

<p>For now, the files needed to implement this first version of the generator are available on GitHub in the <a href="https://github.com/prototipado/ECG_Phantom">Laboratory repository</a>.</p>

<hr />

<p><a href="https://hits.sh/prototipado.github.io/en/developments/ecg-signal-generator/"><img src="https://hits.sh/prototipado.github.io/en/developments/ecg-signal-generator/.svg" alt="Hits" /></a></p>]]></content><author><name>Juan I. Cerrudo</name><email>juan.cerrudo@uner.edu.ar</email></author><category term="Developments" /><category term="ECG" /><category term="Raspberry Pi Pico" /><category term="Python" /><summary type="html"><![CDATA[An ECG signal generator capable of producing multiple leads with enough fidelity to test electrocardiography equipment and run demonstrations.]]></summary></entry><entry xml:lang="en"><title type="html">Pulse Tonometer</title><link href="https://prototipado.github.io/en/tutorial/pulse-tonometer/" rel="alternate" type="text/html" title="Pulse Tonometer" /><published>2026-02-05T00:00:00+00:00</published><updated>2026-02-05T00:00:00+00:00</updated><id>https://prototipado.github.io/tutorial/tonometro-en</id><content type="html" xml:base="https://prototipado.github.io/tutorial/pulse-tonometer/"><![CDATA[<h2 id="introduction">Introduction</h2>

<p>A few years ago, a physician came to the Prototyping Laboratory with the idea of measuring blood pressure using a pulse tonometer.
A pulse tonometer measures blood pressure and pulse-wave characteristics directly over a superficial artery, usually at the wrist. The sensor flattens the radial artery against the bone and measures the pressure needed to keep it compressed.
The idea was to add this capability to <a href="https://github.com/prototipado/bioamp">BioAmp</a>, so we could measure blood pressure and the heart’s electrical activity at the same time. Simultaneous ECG and pressure measurements make it possible to estimate parameters such as pulse transit time (PTT), which correlates with arterial stiffness and is used as an early indicator of cardiovascular risk.</p>

<p>We initially tried to replicate a system that was already being used, based on an invasive pressure sensor. This is a medical device designed for continuous, accurate monitoring of blood pressure and other physiological parameters. It works by inserting a catheter directly into a blood vessel (usually an artery); the catheter transmits pressure to an external transducer, which converts the mechanical signal into an electrical one. The sensor’s tubing was filled with water, and the catheter tip was modified with a flexible membrane (a latex glove finger or a balloon) and placed on the patient’s wrist. Blood pressure moved the membrane and, with it, the water column, making it possible to measure arterial pressure. The sensor was connected to one of BioAmp’s differential channels; its Wheatstone-bridge output produced a voltage difference proportional to the applied pressure, which could be monitored alongside the ECG signal.
This system was not very effective, at least in our implementation. Filling the tubing was difficult, and it was hard to prevent air bubbles from forming inside it. In addition, from a dynamic perspective, the hydraulic system introduced high compliance and strong damping, degrading the fidelity of the pressure waveform and reducing sensor sensitivity.</p>

<h2 id="redesign">Redesign</h2>

<p>We decided to redesign the system from scratch, changing the transducer to make it easier to use. During the literature review we conduct for our projects, patents are one of our sources. One that we found particularly interesting was patent <a href="https://patents.google.com/patent/US20050177047A1/en">US20050177047A1</a>, “Device for, and a method of, transcutaneous pressure waveform sensing of an artery and a related target apparatus.”</p>

<figure class="align-center"><a href="/assets/images/tonometro/US20050177047A1-20050811-D00000.png" class="image-popup" title="Figure from patent US20050177047A1 describing a tonometry system with a gel cone.
"><img src="/assets/images/tonometro/US20050177047A1-20050811-D00000.png" alt="Patent US20050177047A1" /></a><figcaption>
      Figure from patent US20050177047A1 describing a tonometry system with a gel cone.

    </figcaption></figure>

<p>The device illustration showed a pressure sensor and a kind of gel cone that transmitted arterial pressure from the skin surface to the sensor. We were already looking for invasive pressure sensors to assess whether they could be reused, and the illustration made us think we recognized the package of the sensor used. We are not completely certain, but it looks very much like NXP’s <a href="https://www.nxp.com/docs/en/data-sheet/MPX2300D.pdf">MPX2300DT1</a>.</p>

<figure class="align-center"><a href="/assets/images/tonometro/MPX2300DT1.png" class="image-popup" title="NXP MPX2300DT1 pressure sensor, with a package compatible with the one used in invasive monitoring systems.
"><img src="/assets/images/tonometro/MPX2300DT1.png" alt="MPX2300DT1 sensor" /></a><figcaption>
      NXP MPX2300DT1 pressure sensor, with a package compatible with the one used in invasive monitoring systems.

    </figcaption></figure>

<p>This sensor is a silicon pressure transducer rated from 0 to 2.3 MPa (0 to 23 bar), with a temperature-compensated voltage output, used in invasive pressure-monitoring devices.</p>

<h2 id="cad-modeling">CAD Modeling</h2>

<p>We began modeling the device in CAD with the goal of reproducing the geometry described in the patent. We chose silicone for the gel cone and first designed and 3D-printed a PLA mold. The mold was held in a vise to keep it stable during casting.</p>

<figure class="align-center"><a href="/assets/images/tonometro/corte_a.png" class="image-popup" title="Longitudinal section of the CAD model. The yellow part holds the sensor, and the green part secures the cable. The front part is printed in TPU, with only a couple of material layers at the contact surface.
"><img src="/assets/images/tonometro/corte_a.png" alt="Cross-section of the device" /></a><figcaption>
      Longitudinal section of the CAD model. The yellow part holds the sensor, and the green part secures the cable. The front part is printed in TPU, with only a couple of material layers at the contact surface.

    </figcaption></figure>

<p>Instead of conventional liquid silicone (RTV), we used a hot-glue gun. Although commonly called “silicone guns,” the glue sticks are actually made of thermoplastic polymers (usually EVA or other copolymers). Because the solidified material has a similar consistency and is easy to process, we decided to evaluate it as a practical, low-cost alternative.</p>

<figure class="align-center"><a href="/assets/images/tonometro/molde_A.png" class="image-popup" title="PLA-printed mold secured for casting the gel cone. The vent hole at the top helps ensure the cavity fills completely. Aluminum foil was also placed at the mold entrance to protect the PLA from the hot glue.
"><img src="/assets/images/tonometro/molde_A.png" alt="Mold for the gel cone" /></a><figcaption>
      PLA-printed mold secured for casting the gel cone. The vent hole at the top helps ensure the cavity fills completely. Aluminum foil was also placed at the mold entrance to protect the PLA from the hot glue.

    </figcaption></figure>

<p>We needed multiple iterations of both the mold and the gel cone to optimize the geometry and achieve a good casting. The rest of the device also went through several iterations as we looked for a shape that could hold the sensor securely while remaining comfortable to use.</p>

<figure class="align-center"><a href="/assets/images/tonometro/prototipos.jpg" class="image-popup" title="Different iterations of the molds and device structure, aimed at improving functionality and ease of assembly.
"><img src="/assets/images/tonometro/prototipos.jpg" alt="Prototype iterations" /></a><figcaption>
      Different iterations of the molds and device structure, aimed at improving functionality and ease of assembly.

    </figcaption></figure>

<h2 id="testing">Testing</h2>

<p>The video shows the prototype connected to BioAmp, with the pressure signal displayed in BrainBay. This versatile software makes it possible to build a signal-processing flow diagram and obtain blood pressure directly in mmHg.</p>

<p align="center">
  <video controls="" width="80%">
    <source src="/assets/images/tonometro/video_test.mp4" type="video/mp4" />
  </video>
</p>

<p>When ECG capture is added as well, we obtain recordings like this:</p>

<figure class="align-center"><a href="/assets/images/tonometro/ECG_presion.png" class="image-popup" title="Simultaneous ECG and radial blood-pressure waveform captured with the final prototype.
"><img src="/assets/images/tonometro/ECG_presion.png" alt="ECG and pressure recording" /></a><figcaption>
      Simultaneous ECG and radial blood-pressure waveform captured with the final prototype.

    </figcaption></figure>

<p>The redesign produced a pressure signal with a morphology consistent with a physiological radial pulse wave, avoiding the dynamic limitations of the initial hydraulic system. Integration with BioAmp demonstrated that simultaneous ECG and blood-pressure recording is feasible using a low-cost, non-invasive transducer.</p>

<p>Although the system still needs formal calibration against a clinical reference method, the preliminary results validate the concept of integrated radial tonometry and open the way to developing tools for estimating arterial stiffness and derived hemodynamic parameters.</p>

<hr />

<p><a href="https://hits.sh/prototipado.github.io/en/tutorial/pulse-tonometer/"><img src="https://hits.sh/prototipado.github.io/en/tutorial/pulse-tonometer/.svg" alt="Hits" /></a></p>]]></content><author><name>Juan I. Cerrudo</name><email>juan.cerrudo@uner.edu.ar</email></author><category term="Tutorials" /><category term="tonometer" /><category term="design" /><category term="prototyping" /><summary type="html"><![CDATA[Design and prototyping of a non-invasive pulse tonometer integrated with BioAmp for simultaneous ECG and radial blood-pressure recording, aimed at estimating arterial stiffness.]]></summary></entry><entry xml:lang="en"><title type="html">Portable Algometer</title><link href="https://prototipado.github.io/en/tutorial/portable-algometer/" rel="alternate" type="text/html" title="Portable Algometer" /><published>2026-02-05T00:00:00+00:00</published><updated>2026-02-05T00:00:00+00:00</updated><id>https://prototipado.github.io/tutorial/algometro-en</id><content type="html" xml:base="https://prototipado.github.io/tutorial/portable-algometer/"><![CDATA[<h2 id="introduction">Introduction</h2>

<p>Part of the Prototyping Laboratory’s work involves providing technological solutions to other laboratories at the UNER Faculty of Engineering. As part of these activities, we began working some time ago with the <a href="https://ingenieria.uner.edu.ar/nucleos/cirins/">Center for Engineering in Rehabilitation and Neuromuscular and Sensory Research (CIRINS)</a>, supporting one of its research areas: the study of pain.
In the first stage, they asked us to develop an algometer, a device used to measure a patient’s pain threshold. The complete system involved a robotic setup with a linear actuator that applied pressure to the patient’s skin, a force sensor to measure the applied pressure, and a control system for the actuator.</p>

<figure class="align-center"><a href="/assets/images/sensor_dolor/robot.png" class="image-popup" title="Left: algometer and stimulator structure (from ‘Evaluation of a protocol for a pain model using punctate sensation stimuli with a robotic system: a pilot study’). Right: CAD model of the stimulator; the final version has side supports for added rigidity. CAD model of the control system.
"><img src="/assets/images/sensor_dolor/robot.png" alt="Robotic algometer" /></a><figcaption>
      Left: algometer and stimulator structure (from ‘Evaluation of a protocol for a pain model using punctate sensation stimuli with a robotic system: a pilot study’). Right: CAD model of the stimulator; the final version has side supports for added rigidity. CAD model of the control system.

    </figcaption></figure>

<p>In a second stage, they asked us to develop a handheld algometer that could manually measure a patient’s pain threshold, significantly simplifying the system.</p>

<h2 id="redesign">Redesign</h2>

<p>The original control system had three power supplies: one for the linear actuator, one for a stepper motor, and one for the control system. For the manual version, we wanted to simplify this arrangement; by reducing the number of components, we were able to power the entire device from a single supply. The original version used an ESP32 DevKit, but it was out of stock when we placed the order, so we chose a <a href="https://wiki.dfrobot.com/dfr0654/#tech_specs">FireBeetle 2 ESP32-E</a>. This board includes a LiPo battery charger, which will make it easier to add a battery in the future and make the device fully portable.</p>

<p>The user interface consists of a 128×64-pixel OLED display and a rotary encoder with a push button. We also made the FireBeetle board’s buttons available for quick use. The FireBeetle 2 ESP32-E includes LEDs that we used to indicate the device’s status.
The force sensor is the same one used in the original system: Honeywell’s <a href="https://automation.honeywell.com/content/dam/honeywell-edam/sps/common/en-us/industries/healthcare-and-life-sciences/medical-equipment/documents/sps-medical-fma-series.pdf">FMAMSDXX005WC2C3</a>.
We also added a BNC connector, which can be used as an input with a push button (to indicate external events) or as an output to synchronize with other devices.</p>

<h2 id="cad-modeling">CAD Modeling</h2>

<p>We based the physical design on the original system, but simplified the structure to make it significantly more compact and lightweight. The overall structure is a stack of 3D-printed parts held together by three M3×35 mm screws. We chose this approach because it allowed us to iterate quickly on individual sections of the design and made it easier to orient the parts for printing.</p>

<p>We placed the sensor in a kind of cradle built into the plastic part that holds it, and soldered the wires directly to its pads, avoiding the need to add a separate PCB.</p>

<figure class="align-center"><a href="/assets/images/sensor_dolor/sensor.png" class="image-popup" title="Pressure sensor, interior view.
"><img src="/assets/images/sensor_dolor/sensor.png" alt="Pressure sensor" /></a><figcaption>
      Pressure sensor, interior view.

    </figcaption></figure>

<p>Force is transmitted from the external contact point to the internal sensor by a 2.5 mm diameter steel rod. It slides smoothly inside a bronze sleeve and is supported by a plastic part. A spring applies force to the rod, keeping it in constant contact with the sensor with a preload of approximately 120 grams.</p>

<figure class="align-center"><a href="/assets/images/sensor_dolor/estiumulador.png" class="image-popup" title="Manual stimulator, cross-sectional view.
"><img src="/assets/images/sensor_dolor/estiumulador.png" alt="Robotic algometer" /></a><figcaption>
      Manual stimulator, cross-sectional view.

    </figcaption></figure>

<p>We also designed a small handle on the plastic part that supports the rod. It lets us pull the rod away from the sensor temporarily and lock it in place. This protects the delicate sensor from overload while we change the patient’s stimulation tip, which may be pointed, a blunt blade, or another type.</p>

<figure class="align-center"><a href="/assets/images/sensor_dolor/traba_resorte.png" class="image-popup" title="Spring-lock detail: it allows the rod to be separated from the sensor and safely locked while the test tip is changed. The lock is shown in both of its possible positions.
"><img src="/assets/images/sensor_dolor/traba_resorte.png" alt="Spring lock" /></a><figcaption>
      Spring-lock detail: it allows the rod to be separated from the sensor and safely locked while the test tip is changed. The lock is shown in both of its possible positions.

    </figcaption></figure>

<p>We also made a cap that attaches to the front to protect both the stimulator and the internal parts during transport. At the back, we added a cable clamp made from a wedge printed in a soft material (TPU) and a quick-threading nut. This allowed us to secure the cable firmly to the device body and prevent it from being pulled out.</p>

<p>Finally, we designed the control-module enclosure as two large interlocking parts joined by three M3 screws.</p>

<figure class="align-center"><a href="/assets/images/sensor_dolor/gabinete.png" class="image-popup" title="Control-system enclosure, interior view.
"><img src="/assets/images/sensor_dolor/gabinete.png" alt="Enclosure" /></a><figcaption>
      Control-system enclosure, interior view.

    </figcaption></figure>

<p>To make the FireBeetle board’s tactile buttons easy to use, we designed and printed small flexible plastic structures that press the on-board buttons when pushed from the outside. We had used similar solutions before, but in this case the FireBeetle buttons were soldered at 90 degrees. This orientation required more careful analysis to ensure the natural rotation of the printed part produced the optimal linear movement to trigger the button’s internal mechanical click without damaging it.</p>

<p align="center">
  <video autoplay="" loop="" muted="" playsinline="" width="80%">
    <source src="/assets/images/sensor_dolor/button_desplazamiento.webm" type="video/webm" />
  </video>
</p>

<p>The stimulation tip used in this particular case is a hypodermic needle whose bevel was removed to prevent cuts and skin penetration. We considered printing a Luer-style lock to hold the needle, but the part was too small and complex, so we chose to reuse a plastic lock from a syringe.</p>

<figure class="align-center"><a href="/assets/images/sensor_dolor/luer.jpeg" class="image-popup" title="Reused Luer lock for attaching the needle.
"><img src="/assets/images/sensor_dolor/luer.jpeg" alt="Luer lock" /></a><figcaption>
      Reused Luer lock for attaching the needle.

    </figcaption></figure>

<h2 id="testing">Testing</h2>

<p>To test the device, we printed additional mounting parts. These let us firmly clamp the stimulator in a vise and place a standard 100-gram weight on the actuator.</p>

<figure class="align-center"><a href="/assets/images/sensor_dolor/setup_pruebas.jpg" class="image-popup" title="Test setup, with the sensor resting on a 100-gram weight.
"><img src="/assets/images/sensor_dolor/setup_pruebas.jpg" alt="Test setup" /></a><figcaption>
      Test setup, with the sensor resting on a 100-gram weight.

    </figcaption></figure>

<p>We captured data with no load and with the weight on the sensor, at both 10 Hz (the normal operating frequency with real-time display) and 500 Hz (the maximum frequency during measurements).</p>

<figure class="align-center"><a href="/assets/images/sensor_dolor/analisis_0gr_10Hz.png" class="image-popup" title="Signal analysis results with no load (0 g) at 10 Hz.
"><img src="/assets/images/sensor_dolor/analisis_0gr_10Hz.png" alt="No-load analysis at 10 Hz" /></a><figcaption>
      Signal analysis results with no load (0 g) at 10 Hz.

    </figcaption></figure>

<figure class="align-center"><a href="/assets/images/sensor_dolor/analisis_0gr_500Hz.png" class="image-popup" title="Signal analysis results with no load (0 g) at 500 Hz.
"><img src="/assets/images/sensor_dolor/analisis_0gr_500Hz.png" alt="No-load analysis at 500 Hz" /></a><figcaption>
      Signal analysis results with no load (0 g) at 500 Hz.

    </figcaption></figure>

<figure class="align-center"><a href="/assets/images/sensor_dolor/analisis_100gr_10Hz.png" class="image-popup" title="Signal analysis results with a 100 g load at 10 Hz.
"><img src="/assets/images/sensor_dolor/analisis_100gr_10Hz.png" alt="100-gram load analysis at 10 Hz" /></a><figcaption>
      Signal analysis results with a 100 g load at 10 Hz.

    </figcaption></figure>

<figure class="align-center"><a href="/assets/images/sensor_dolor/analisis_100gr_500Hz.png" class="image-popup" title="Signal analysis results with a 100 g load at 500 Hz.
"><img src="/assets/images/sensor_dolor/analisis_100gr_500Hz.png" alt="100-gram load analysis at 500 Hz" /></a><figcaption>
      Signal analysis results with a 100 g load at 500 Hz.

    </figcaption></figure>

<p>When analyzing the sensor measurements, the first thing we see is that the average values are accurate: when a 100 g load is applied, the system measures very close to that value. However, looking at the signal in detail over long periods reveals small variations and slow drifts. These are not random noise, but effects intrinsic to the sensor, such as temperature changes or internal mechanical settling. This is also visible in the histograms, where the distribution is not perfectly clean, and in the frequency analysis, which shows that nearly all the variation happens very slowly, close to zero Hz.</p>

<p>In actual use, however, things are quite different. If the sensor is zeroed immediately before a measurement and used for only a few seconds, these slow effects are no longer a significant problem. Over that short interval, the system is much more stable, and what dominates is simply a little electrical noise in the signal. In practice, this means the sensor works well for quick measurements as long as it is calibrated beforehand. The reading can be smoothed further by averaging a few values. In short, although it is not ideal for prolonged measurements without recalibration, it is perfectly usable and reliable in short, controlled scenarios, where it performs best.</p>

<p align="center">
  <video controls="" width="80%">
    <source src="/assets/images/sensor_dolor/video.mp4" type="video/mp4" />
  </video>
</p>

<hr />

<p><a href="https://hits.sh/prototipado.github.io/en/tutorial/portable-algometer/"><img src="https://hits.sh/prototipado.github.io/en/tutorial/portable-algometer/.svg" alt="Hits" /></a></p>]]></content><author><name>Juan I. Cerrudo</name><email>juan.cerrudo@uner.edu.ar</email></author><category term="Tutorials" /><category term="algometer" /><category term="design" /><category term="prototyping" /><summary type="html"><![CDATA[Development of a handheld portable algometer for measuring pain threshold, featuring a Honeywell force sensor, OLED display, rotary encoder, and 3D-printed structure.]]></summary></entry></feed>