36 KiB
AiP650E Protocol Proxy Firmware
Version: 1.0
Date: April 2026
Microcontroller: ATmega328P (28-pin DIP)
Protocol: I2C Slave @ 0x50
Purpose: Capture AiP650E display protocol, memory keys, and expose via I2C to ESP32-S3
1. Overview
This firmware runs on an ATmega328P microcontroller to act as a protocol bridge. It captures:
- Original microcontroller's AiP650E display commands via CLK/DIO lines
- Memory key button states (KEY_1, KEY_2, KEY_3 with common)
- Exposes all data to the ESP32-S3 via I2C
This isolates timing-critical protocol capture from the ESP32's rendering and connectivity tasks. The ATmega328P provides ample GPIO for all sensors and debug capability via UART.
2. Pin Assignments
┌──────────┐
PD0 ├───●───●──┤ PD1
PD2 ├──────────┤ GND
PD3 ├──────────┤ AREF
PD4 ├──────────┤ AV
VCC ├──────────┤ GND
GND ├──────────┤ PB6
PB7 ├──────────┤ PB5
PD5 ├──────────┤ PB4
PD6 ├──────────┤ PB3
PD7 ├──────────┤ PB2
PB0 ├──────────┤ PB1
PC0 ├──────────┤ PC1
└──────────┘
ATmega328P (top view)
| Pin # | Port | Function | Connect To | Notes |
|---|---|---|---|---|
| 1 | PC6 | RESET | ISP Header Pin 6 | Reset signal (ISP programming) |
| 2 | PD0 | RXD | USB-UART adapter TX | Serial debugging (optional) |
| 3 | PD1 | TXD | USB-UART adapter RX | Serial debugging output |
| 4 | PD2 | (spare) | — | Available for future use |
| 5 | PD3 | (spare) | — | Available for future use |
| 6 | PD4 | KEY_1 Input | Display Pin 13 (KEY_1) | Memory key 1 button input |
| 7 | VCC | Power | ESP32 3.3V | Main power supply |
| 8 | GND | Ground | Common GND | Ground reference |
| 11 | PD5 | KEY_2 Input | Display Pin 9 (KEY_2) | Memory key 2 button input |
| 12 | PD6 | KEY_3 Input | Display Pin 8 (KEY_3) | Memory key 3 button input |
| 13 | PD7 | KEY_COMMON | Display Pin 7 (KEY_COMMON) | Common line for all memory keys |
| 14 | PB0 | SCL (I2C) | ESP32 GPIO21 | I2C Clock, with 4.7k pull-up to 3.3V |
| 15 | PB1 | SDA (I2C) | ESP32 GPIO20 | I2C Data, with 4.7k pull-up to 3.3V |
| 16 | PB2 | CLK Input | Display Pin 2 (SEGM_CLK) | Interrupt input for protocol capture |
| 17 | PB3 | MOSI (ISP) | ISP Header Pin 1 | SPI Data In (ISP programmer) |
| 18 | PB4 | MISO (ISP) | ISP Header Pin 5 | SPI Data Out (ISP programmer) |
| 19 | PB5 | SCK (ISP) | ISP Header Pin 3 | SPI Clock (ISP programmer) |
| 20 | AVCC | Analog Ref | VCC | Tie to VCC with 0.1uF cap |
| 21 | PC1 | LED Output | LED Anode (via 330Ω resistor) | Activity LED indicator (active HIGH) |
| 22 | GND | Ground | Common GND | Ground reference |
| 23 | PC0 | DIO Input | Display Pin 3 (SEGM_DIO) | Data line (open-drain output) |
| 24 | PC2 | UP Button | Button to GND | UP button input (pull-up enabled) |
| 25 | PC3 | DOWN Button | Button to GND | DOWN button input (pull-up enabled) |
Advantages of ATmega328P:
- ✅ More GPIO pins (can route buttons directly)
- ✅ Hardware UART (easy serial debugging via
Serial.println()) - ✅ Larger flash (4x more code space)
- ✅ Serial bootloader (reprogram via serial instead of ISP)
- ✅ Better for custom PCB with level shifter
2.2a ISP Programming Header (ATmega328P)
Standard 6-pin ISP Header Layout:
┌─────────────┐
│ 1 2 3 4 │
│ 5 6 │
└─────────────┘
ISP Header (top view)
| Header Pin | Signal | ATmega328P Pin | Arduino Pin | Connect |
|---|---|---|---|---|
| 1 | MOSI | PB3 (pin 17) | 11 | SPI Data In |
| 2 | VCC | Pin 7 | Power | Power supply |
| 3 | SCK | PB5 (pin 19) | 13 | SPI Clock |
| 4 | GND | Pin 8/22 | GND | Ground |
| 5 | MISO | PB4 (pin 18) | 12 | SPI Data Out |
| 6 | RESET | PC6 (pin 1) | RESET | Reset signal |
Advantages for ATmega328P:
- ✅ ISP pins separate from I2C (no conflicts!)
- ✅ Can keep I2C running during ISP programming
- ✅ Serial bootloader option (program via UART instead of ISP)
- ✅ Larger PCB footprint allows better ISP header access
2.1a ISP Programming Header
Standard 6-pin ISP Header Layout:
┌─────────────┐
│ 1 2 3 4 │
│ 5 6 │
└─────────────┘
ISP Header (top view)
| Header Pin | Signal | ATmega328P Pin | Arduino Pin | Connect |
|---|---|---|---|---|
| 1 | MOSI | PB3 (pin 17) | 11 | SPI Data In |
| 2 | VCC | Pin 7 | Power | Power supply |
| 3 | SCK | PB5 (pin 19) | 13 | SPI Clock |
| 4 | GND | Pin 8/22 | GND | Ground |
| 5 | MISO | PB4 (pin 18) | 12 | SPI Data Out |
| 6 | RESET | PC6 (pin 1) | RESET | Reset signal |
ISP Pin Notes:
- ISP pins separate from I2C (no conflicts!)
- Can keep I2C running during ISP programming
- Serial bootloader option (program via UART instead of ISP)
Recommended PCB Layout:
- Add 6-pin ISP header (standard 2×3 pin spacing: 100 mil)
- Add 6-pin FTDI serial header for bootloader reprogramming (optional)
- Mount headers on PCB edge for easy access
- Label pins clearly to match programmer cable
2.1b Memory Key & Button Input Configuration
Display Driver Memory Keys (Direct Connection to ATmega328P):
Display Driver: ATmega328P:
Pin 2 (CLK) ------→ PB2 (pin 16) [CLK]
Pin 3 (DIO) ------→ PC0 (pin 23) [DIO]
Pin 7 (KEY_COMMON) -----→ PD7 (pin 13) [Key Common]
Pin 8 (KEY_3) ------→ PD6 (pin 12) [Key 3]
Pin 9 (KEY_2) ------→ PD5 (pin 11) [Key 2]
Pin 13 (KEY_1) ------→ PD4 (pin 6) [Key 1]
Desk Control Buttons (Direct Connection to ATmega328P):
UP Button (desk) ----→ PC2 (pin 24) [UP]
DOWN Button (desk) ----→ PC3 (pin 25) [DOWN]
All button inputs use internal pull-ups (configured via INPUT_PULLUP in setup).
Button State Register (0x04):
Bit 0: KEY_1 (from memory matrix)
Bit 1: KEY_2 (from memory matrix)
Bit 2: KEY_3 (from memory matrix)
Bit 3: UP Button (direct connection)
Bit 4: DOWN Button (direct connection)
Bits 7-5: Reserved
Key Debouncing:
- Software debounce: 20ms minimum between reads
- Optional: Add 100nF capacitor between each button pin and GND for hardware debounce
Input Logic (Active LOW):
// Memory keys: KEY_COMMON pulled LOW enables the matrix
// When a key is pressed, that KEY_X line goes LOW
if (digitalRead(KEY_COMMON) == LOW) {
if (digitalRead(KEY_1) == LOW) button_state |= 0x01; // Bit 0
if (digitalRead(KEY_2) == LOW) button_state |= 0x02; // Bit 1
if (digitalRead(KEY_3) == LOW) button_state |= 0x04; // Bit 2
}
// UP/DOWN buttons: Direct connections, also active LOW
if (digitalRead(UP_BTN) == LOW) button_state |= 0x08; // Bit 3
if (digitalRead(DOWN_BTN) == LOW) button_state |= 0x10; // Bit 4
Pin Definitions (C++):
#define CLK_PIN PB2 // Pin 16
#define DIO_PIN PC0 // Pin 23
#define KEY_1 PD4 // Pin 6
#define KEY_2 PD5 // Pin 11
#define KEY_3 PD6 // Pin 12
#define KEY_COMMON PD7 // Pin 13
#define UP_BTN PC2 // Pin 24
#define DOWN_BTN PC3 // Pin 25
#define LED_PIN PC1 // Pin 21
2.1c Minimal Wiring Diagram
┌────────────────────────────────────────────────────────────────┐
│ Display Driver Board │
│ Pin 2 (CLK) Pin 3 (DIO) │
│ Pin 7,8,9,13 (KEY matrix) │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ └─┐ │
│ │ │ │ │ └──┬┤ │
│ │ │ │ └────┼┤ │
│ │ │ └──────┼┤ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ATmega328P (28-pin QFN) │
│ ┌──────────────────────────────────┐ │
│ │ Pin 16 PB2 (CLK) │ │
│ │ Pin 23 PC0 (DIO) │ │
│ │ Pin 6 PD4 (KEY_1) │ │
│ │ Pin 11 PD5 (KEY_2) │ │
│ │ Pin 12 PD6 (KEY_3) │ │
│ │ Pin 13 PD7 (KEY_COMMON) │ │
│ │ Pin 21 PC1 (LED) ───[330Ω]────→ │ ● LED (active HIGH) │
│ │ Pin 24 PC2 (UP) ──→ ○ UP BTN │ │
│ │ Pin 25 PC3 (DOWN) ──→ ○ DOWN BTN │ │
│ │ Pin 14 PB0 (I2C SCL) ┬──────┐ │ │
│ │ Pin 15 PB1 (I2C SDA) │ │ │ │
│ │ [4.7k pull-ups] │ │ │ │
│ │ Pin 7 VCC ◄──────────┴──┬───────→ 3.3V │
│ │ Pin 8 GND ────────────┬─┴────→ GND │
│ │ Pin 17 PB3 ──┐ [ISP]│ │
│ │ Pin 18 PB4 ──┼─────┤ │ │
│ │ Pin 19 PB5 ──┤ │ │ │
│ │ Pin 1 PC6 ──┘ │ │ │
│ └─────────────────────┼──┼─────────┘ │
│ │ │ │
│ │ └────────→ ESP32-S3 GPIO21 (SCL) │
│ └──────────→ ESP32-S3 GPIO20 (SDA) │
│ │
│ [ISP Programmer] │
│ ├─ Pin 1: MOSI │
│ ├─ Pin 2: VCC │
│ ├─ Pin 3: SCK │
│ ├─ Pin 4: GND │
│ ├─ Pin 5: MISO │
│ └─ Pin 6: RESET │
└────────────────────────────────────────────────────────────────┘
Connections Summary:
- Display Protocol: CLK (Pin 2) → PB2, DIO (Pin 3) → PC0
- Memory Keys: KEY_COMMON (Pin 7) → PD7, KEY_1/2/3 (Pins 13/9/8) → PD4/5/6
- UP/DOWN Buttons: Desk buttons → PC2/PC3 (active LOW, pull-ups enabled)
- Activity LED: PC1 → 330Ω resistor → LED anode (cathode to GND)
- I2C to ESP32: PB0/PB1 → GPIO21/20 with 4.7k pull-ups
- ISP Programmer: 6-pin header for initial firmware flashing
- Serial Debug: PD0/PD1 optional for USB-UART debugging
3. ATmega328P Firmware Architecture
3.1 Software Structure
┌─────────────────────────────────┐
│ Main Loop (State Machine) │
│ - Monitor CLK interrupts │
│ - Decode protocol frames │
│ - Update display registers │
│ - Poll memory keys │
│ - Service I2C requests │
└────────────┬────────────────────┘
│
┌──────┴──────┬──────────┬──────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌──────┐ ┌─────────┐ ┌───────┐ ┌────────┐
│ CLK │ │ DIO │ │ Frame │ │ I2C │
│ISR │ │Sampling │ │Parser │ │Handler │
└──────┘ └─────────┘ └───────┘ └────────┘
3.2 I2C Register Map
Slave Address: 0x50 (0xA0 write, 0xA1 read in standard notation)
| Addr | Name | Type | Value | Description |
|---|---|---|---|---|
| 0x00 | DIG1 | byte | 0-9 | Display digit 1 (hundreds) - decoded value |
| 0x01 | DIG2 | byte | 0-9 | Display digit 2 (tens) - decoded value |
| 0x02 | DIG3 | byte | 0-9 | Display digit 3 (ones) - decoded value |
| 0x03 | STAT | byte | bits | Display status (power, brightness, mode) |
| 0x04 | BTNS | byte | bits | Button states via AiP650E (if available) |
| 0x10 | VERSION | byte | 0x10 | Firmware version (v1.0) |
| 0x11 | ERRORS | byte | bits | Error/status flags |
STAT Byte (0x03):
Bit 7: Display Power (1=on, 0=off)
Bit 6-4: Brightness (0-7)
Bit 3: SEG Mode (1=7-segment, 0=8-segment)
Bit 2: Sleep Mode (1=enabled, 0=disabled)
Bit 1-0: Reserved
ERRORS Byte (0x11):
Bit 7: I2C Comm Error (1=error, 0=ok)
Bit 6: Frame Timeout (1=no CLK for >1s, 0=ok)
Bit 5: Protocol Error (1=malformed frame, 0=ok)
Bit 4: Checksum Mismatch (1=error, 0=ok)
Bit 3-0: Reserved
3.3 Protocol Capture State Machine
IDLE
↓
[Wait for START condition: CLK high, DIO falling edge]
↓
RECEIVING
├─ Clock bit counter = 0
├─ Byte buffer = 0
├─ [For each CLK rising edge]
│ ├─ Sample DIO
│ ├─ Shift bit into buffer
│ ├─ Increment counter
│ └─ [If counter = 8]
│ ├─ Store byte
│ ├─ Reset counter
│ └─ [Decode command if 2 bytes received]
│ ├─ Check if display data write (RAM addr 0x68/0x6A/0x6C)
│ ├─ If yes: Update shadow RAM (NEW data from original micro)
│ └─ If no (brightness/mode/status): Just track state
│
└─ [Wait for STOP condition: CLK high, DIO rising edge]
↓
IDLE
3.4 Shadow RAM & Display Blanking Logic
Problem: When the original micro blanks the display (e.g., sleep timeout), it may:
- Reduce brightness to 0 (segment data still valid but not displayed)
- Clear the segment RAM entirely (segment data becomes 0x00 or noise)
Solution: ATtiny85 maintains a "shadow" copy of the last VALID display data:
// Persistent shadow RAM (only updated on ACTUAL data writes, not blanking)
volatile uint8_t shadow_dig1 = 0xFF;
volatile uint8_t shadow_dig2 = 0xFF;
volatile uint8_t shadow_dig3 = 0xFF;
// Exposed to ESP32 (never shows blanked/corrupt data)
uint8_t dig1 = 0xFF;
uint8_t dig2 = 0xFF;
uint8_t dig3 = 0xFF;
// Last known state for detecting changes
volatile uint8_t last_display_stat = 0x00;
volatile uint8_t last_brightness = 0xFF;
Detection Algorithm:
-
When a write to RAM address 0x68/0x6A/0x6C is detected:
- Extract segment data
- Check if it's different from ALL ZEROS (0x00)
- If non-zero: It's real data → update shadow RAM
- If zero: Could be blanking → ignore (keep shadow RAM)
-
If brightness drops to 0 (display going dark):
- Don't clear shadow RAM
- Keep last known display value available
-
ESP32 always reads from shadow RAM:
- Gets last actual displayed value
- Not affected by display blanking
- Shows "250" even after display goes dark
Pseudocode:
void on_display_data_write(uint8_t ram_addr, uint8_t segment_byte) {
uint8_t decoded_digit = decode_segment(segment_byte);
if (ram_addr == 0x68) {
// DIG1 write
if (decoded_digit != 0xFF) { // Valid digit (not all zeros/noise)
shadow_dig1 = decoded_digit;
}
// If 0xFF or 0x00, don't update shadow (keep last value)
}
else if (ram_addr == 0x6A) {
// DIG2 write
if (decoded_digit != 0xFF) {
shadow_dig2 = decoded_digit;
}
}
else if (ram_addr == 0x6C) {
// DIG3 write
if (decoded_digit != 0xFF) {
shadow_dig3 = decoded_digit;
}
}
}
void i2c_request() {
// Always return shadow RAM values (not affected by display blanking)
switch (reg_ptr) {
case 0x00: TinyWireS.send(shadow_dig1); break;
case 0x01: TinyWireS.send(shadow_dig2); break;
case 0x02: TinyWireS.send(shadow_dig3); break;
// ... etc
}
}
4. Implementation Notes
4.1 Display Blanking Behavior (Important!)
The ATtiny85 implements shadow RAM to handle display blanking gracefully:
Scenario: Original desk controller blanks the display after timeout (power saving):
- What happens: Brightness reduced to 0 or segment RAM cleared
- Without shadow RAM: ESP32 would read 0xFF or corrupted data
- With shadow RAM: ESP32 always sees the last valid display value
Example:
Time 0: Display shows "250" → shadow_dig1=2, shadow_dig2=5, shadow_dig3=0
Time 60s: Display blanks (sleep timeout) → brightness=0
Time 60s: ESP32 reads registers → gets "250" (from shadow RAM, not blanked display!)
Time 120s: User presses button, display wakes → shows "250" again
Time 120s: ESP32 still reads "250" (no interruption)
Shadow RAM Logic:
-
When a display data write is detected (RAM addr 0x68/0x6A/0x6C):
- Decode the segment data to a digit (0-9)
- If valid digit: Update shadow RAM
- If invalid (0xFF or 0x00): Skip update, keep existing shadow value
-
ESP32 always reads from shadow RAM (not live display data)
- Immune to display blanking
- Shows persistent height value
- Updates only when original micro writes new data
Benefits:
- ✅ No "NaN" or 0xFF errors on display blanking
- ✅ ESP32 touchscreen shows last height even when original display is dark
- ✅ Seamless user experience during sleep mode
4.2 Microcontroller Configuration
ATmega328P Fuses (Internal 8 MHz option):
lfuse: 0xE2 (8 MHz internal oscillator, ~5% accuracy)
hfuse: 0xDA (BOD disabled, reset enabled)
efuse: 0xFF (default)
- Oscillator: Internal 8 MHz (no crystal needed for I2C/serial debug)
- RESET: Enabled (required for ISP programming)
- Bootloader: Optional (if you want serial reprogramming)
OR with External 16 MHz Crystal (higher precision):
lfuse: 0xFF (external crystal)
hfuse: 0xDE (BOD disabled, reset enabled, bootloader space)
efuse: 0xFF (default)
- Add 16 MHz crystal between XTAL1/XTAL2 with 22pF caps to GND
- Allows precise serial baud rates and higher clock speed
4.3 CLK Interrupt Handler
// Interrupt on CLK rising edge (PB2 = PCINT2)
ISR(PCINT0_vect) {
if (digitalRead(CLK_PIN) == HIGH && prev_clk == LOW) {
// Rising edge detected
uint8_t dio_bit = digitalRead(DIO_PIN);
// Store bit in shift register
bit_buffer <<= 1;
bit_buffer |= (dio_bit & 1);
bit_count++;
if (bit_count == 8) {
// Full byte received
byte_buffer[byte_count++] = bit_buffer;
bit_count = 0;
if (byte_count == 2) {
// Frame complete, parse it
parse_command(byte_buffer);
byte_count = 0;
}
}
}
prev_clk = digitalRead(CLK_PIN);
}
4.4 I2C Slave Handler
// ATmega328P I2C handler using Wire library
#include <Wire.h>
#define I2C_ADDR 0x50
void setup() {
Wire.begin(I2C_ADDR);
Wire.onReceive(i2c_receive);
Wire.onRequest(i2c_request);
}
void i2c_receive(uint8_t count) {
if (Wire.available() >= 1) {
reg_ptr = Wire.read(); // Get register address
}
}
void i2c_request() {
uint8_t value;
switch (reg_ptr) {
case 0x00: value = shadow_dig1; break;
case 0x01: value = shadow_dig2; break;
case 0x02: value = shadow_dig3; break;
case 0x03: value = display_stat; break;
case 0x04: value = button_state; break; // Memory key state
case 0x10: value = 0x10; break; // VERSION
case 0x11: value = error_flags; break;
default: value = 0xFF; break;
}
Wire.write(value);
reg_ptr++; // Auto-increment for sequential reads
}
4.5 7-Segment Decoder
// Decode segment byte to digit value
uint8_t decode_segment(uint8_t segments) {
// segments format: bit0=A, bit1=B, ... bit7=DP
const uint8_t digit_patterns[10] = {
0x3F, // 0: A B C D E F (no G, no DP)
0x06, // 1: B C
0x5B, // 2: A B D E G
0x4F, // 3: A B C D G
0x66, // 4: B C F G
0x6D, // 5: A C D F G
0x7D, // 6: A C D E F G
0x07, // 7: A B C
0x7F, // 8: A B C D E F G
0x6F // 9: A B C D F G
};
uint8_t seg_no_dp = segments & 0x7F; // Mask off DP
for (uint8_t i = 0; i < 10; i++) {
if (digit_patterns[i] == seg_no_dp) {
return i;
}
}
return 0xFF; // Unknown pattern
}
4.5 Memory Usage
ATtiny85 Resources:
- Flash: 8 KB total
- Used: ~2-3 KB (protocol + I2C handler)
- Available: ~5-6 KB
- SRAM: 512 bytes total
- Used: ~150-200 bytes
- Available: ~300+ bytes
- EEPROM: 512 bytes (unused)
5. Build & Programming
5.1 Arduino IDE Setup
- Install standard AVR support (pre-installed with Arduino IDE)
- Board: "Arduino Uno" (uses same ATmega328P)
- Clock: "16 MHz" (with external oscillator) OR "8 MHz" (internal, if you add fuse change)
- Programmer: Select your ISP programmer or use serial bootloader
- ISP: USbasp, Arduino as ISP, AVRISP mkII
- Serial: "Arduino" (if bootloader pre-programmed)
Required Libraries:
#include <Wire.h> // Standard I2C for ATmega328P
#include <HardwareSerial.h> // Serial for debugging (optional)
5.2 ISP Programmer Setup
Common ISP Programmers:
| Programmer | Cost | Speed | Notes |
|---|---|---|---|
| USbasp | $2-5 | ~370 kHz | Most affordable, widely available |
| Arduino as ISP | $0 | ~125 kHz | Use spare Arduino Uno/Nano |
| AVRISP mkII | $40-50 | ~375 kHz | Official Atmel programmer |
| USBISP | $5-10 | ~375 kHz | Similar to USbasp |
Connection (Standard 6-pin ISP Header):
ISP Programmer
(USB connector)
│
┌────┴────┐
│ ISP Cable │ 6-wire ribbon
└────┬────┘
│
┌─────────────────────┐
│ Interposer PCB │
│ ┌───────────────┐ │
│ │ ISP Header │ │
│ │ (6-pin box) │ │
│ │ │ │
│ │ 1 2 3 4 │ │
│ │ 5 6 │ │
│ └───────────────┘ │
│ │
│ [Microcontroller] │
└─────────────────────┘
5.3 Programming Method
ISP Programming (Initial Setup)
# Option 1: Arduino IDE (easiest)
# 1. Connect USbasp/Arduino to ISP header
# 2. Tools → Programmer → [select your programmer]
# 3. Tools → Burn Bootloader (sets fuses + installs bootloader)
# 4. Sketch → Upload Using Programmer (or Ctrl+Shift+U)
# Option 2: Command line with avrdude
avrdude -c usbasp -p m328p -U flash:w:firmware.hex:i \
-U lfuse:w:0xe2:m -U hfuse:w:0xda:m
# Option 3: Verify ISP connection first
avrdude -c usbasp -p m328p -v
Serial Bootloader Reprogramming (After Initial ISP)
# After initial ISP programming with bootloader:
# 1. Connect USB-UART adapter to PD0 (RXD) and PD1 (TXD)
# 2. Select Tools → Programmer → "Arduino"
# 3. Sketch → Upload (standard upload, not "Upload Using Programmer")
# 4. Baud rate should auto-detect
# Much faster than ISP! Reuses Arduino serial bootloader.
⚠️ Important: ISP Header Labels
Always label your ISP header clearly on the PCB:
[ISP Header]
┌─────────────────┐
│ MOSI VCC │ (Pin 1: MOSI, Pin 2: VCC)
│ SCK GND │ (Pin 3: SCK, Pin 4: GND)
│ MISO RESET │ (Pin 5: MISO, Pin 6: RESET)
└─────────────────┘
Standard 6-pin ISP pinout
5.4 Troubleshooting ISP
| Problem | Cause | Solution |
|---|---|---|
| "Cannot find programmer" | Wrong programmer selected | Tools → Programmer → check selection |
| ISP connection fails | Bad cable or header | Test with multimeter, reseat header |
| "Fuse mismatch" | Fuses different than expected | Use correct fuses for 8 MHz or 16 MHz |
| Extremely slow programming | ISP speed too fast | Add -B flag: -B 10 to avrdude |
| "Timeout talking to programmer" | USB/serial issue | Try different USB port, different cable |
6. Firmware Code Template
#include <avr/interrupt.h>
#include <avr/io.h>
#include <Wire.h>
// Pin definitions (ATmega328P 28-pin QFN)
#define CLK_PIN PB2 // Pin 16 (PCINT2)
#define DIO_PIN PC0 // Pin 23 (input)
#define KEY_1 PD4 // Pin 6 (input)
#define KEY_2 PD5 // Pin 11 (input)
#define KEY_3 PD6 // Pin 12 (input)
#define KEY_COMMON PD7 // Pin 13 (input)
#define LED_PIN PC1 // Pin 21 (output, activity indicator)
#define UP_BTN PC2 // Pin 24 (input, UP button)
#define DOWN_BTN PC3 // Pin 25 (input, DOWN button)
// I2C configuration
#define I2C_ADDR 0x50
// Display data (exposed via I2C to ESP32)
volatile uint8_t dig1 = 0xFF, dig2 = 0xFF, dig3 = 0xFF;
// Shadow RAM - persistent storage of last VALID display data
// Only updated when original micro writes NEW data, NOT when display blanks
volatile uint8_t shadow_dig1 = 0xFF, shadow_dig2 = 0xFF, shadow_dig3 = 0xFF;
volatile uint8_t display_stat = 0x00;
volatile uint8_t button_state = 0x00;
volatile uint8_t error_flags = 0x00;
// Protocol state
volatile uint8_t bit_buffer = 0;
volatile uint8_t bit_count = 0;
volatile uint8_t byte_buffer[2];
volatile uint8_t byte_count = 0;
volatile uint8_t prev_clk = 0;
volatile uint32_t last_clk_time = 0;
// Display write tracking (to distinguish data writes from display blanking)
volatile uint8_t last_ram_addr = 0xFF;
volatile uint8_t last_segment_data = 0xFF;
uint8_t reg_ptr = 0;
// 7-segment decoder lookup
const uint8_t digit_patterns[10] = {
0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F
};
// ===== 7-Segment Decoder =====
uint8_t decode_segment(uint8_t segments) {
uint8_t seg_no_dp = segments & 0x7F; // Mask off DP bit
for (uint8_t i = 0; i < 10; i++) {
if (digit_patterns[i] == seg_no_dp) {
return i; // Return digit 0-9
}
}
return 0xFF; // Unknown/invalid pattern
}
// ===== ISR: CLK Pin Change =====
ISR(PCINT0_vect) {
if (digitalRead(CLK_PIN) && !prev_clk) {
// CLK rising edge
uint8_t dio_bit = digitalRead(DIO_PIN);
bit_buffer = (bit_buffer << 1) | (dio_bit & 1);
bit_count++;
last_clk_time = millis();
if (bit_count == 8) {
byte_buffer[byte_count++] = bit_buffer;
bit_count = 0;
if (byte_count == 2) {
parse_command();
byte_count = 0;
}
}
}
prev_clk = digitalRead(CLK_PIN);
}
// ===== Parse AiP650E Command =====
void parse_command() {
uint8_t sys_instr = byte_buffer[0];
uint8_t disp_instr = byte_buffer[1];
if (sys_instr == 0x48) { // System instruction
// Parse display instruction
if (bit_is_clear(disp_instr, 0)) {
display_stat &= ~0x80; // Display OFF
} else {
display_stat |= 0x80; // Display ON
}
// Brightness (bits 5-3)
uint8_t brightness = (disp_instr >> 3) & 0x07;
display_stat = (display_stat & 0x8F) | (brightness << 4);
// SEG mode (bit 3 of disp_instr -> bit 3 of stat)
if (bit_is_set(disp_instr, 3)) {
display_stat |= 0x08; // 7-segment
} else {
display_stat &= ~0x08; // 8-segment
}
}
else if (sys_instr >= 0x68 && sys_instr <= 0x6E) {
// Display data write to RAM (0x68=DIG1, 0x6A=DIG2, 0x6C=DIG3)
uint8_t segment_data = disp_instr;
uint8_t decoded = decode_segment(segment_data);
// SHADOW RAM LOGIC:
// Only update if we get a valid digit (0-9)
// Ignore if we get 0xFF (unknown) or 0x00 (blanking/noise)
if (sys_instr == 0x68) {
if (decoded != 0xFF) { // Valid digit
shadow_dig1 = decoded;
dig1 = decoded; // Also update exposed register
}
// If 0xFF or blank, keep shadow_dig1 unchanged
}
else if (sys_instr == 0x6A) {
if (decoded != 0xFF) {
shadow_dig2 = decoded;
dig2 = decoded;
}
}
else if (sys_instr == 0x6C) {
if (decoded != 0xFF) {
shadow_dig3 = decoded;
dig3 = decoded;
}
}
last_ram_addr = sys_instr;
last_segment_data = segment_data;
}
}
// ===== I2C Receive Handler =====
void i2c_receive(uint8_t count) {
if (Wire.available() >= 1) {
reg_ptr = Wire.read(); // Get register address
}
}
// ===== I2C Request Handler =====
void i2c_request() {
uint8_t value = 0xFF;
switch (reg_ptr) {
// Return shadow RAM values (persistent across display blanking)
case 0x00: value = shadow_dig1; break;
case 0x01: value = shadow_dig2; break;
case 0x02: value = shadow_dig3; break;
// Status always returns current state
case 0x03: value = display_stat; break;
case 0x04: value = button_state; break; // Memory key state
case 0x10: value = 0x10; break; // VERSION
case 0x11: value = error_flags; break;
default: value = 0xFF; break;
}
Wire.write(value);
reg_ptr++; // Auto-increment for sequential reads
}
// ===== Setup =====
void setup() {
// Configure input pins
pinMode(CLK_PIN, INPUT);
pinMode(DIO_PIN, INPUT);
pinMode(KEY_1, INPUT_PULLUP);
pinMode(KEY_2, INPUT_PULLUP);
pinMode(KEY_3, INPUT_PULLUP);
pinMode(KEY_COMMON, INPUT_PULLUP);
pinMode(UP_BTN, INPUT_PULLUP); // UP button input
pinMode(DOWN_BTN, INPUT_PULLUP); // DOWN button input
// Configure output pins
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW); // LED off at startup
// Enable pin change interrupt on CLK (PB2 = PCINT2)
// PCINT group 0 covers PB0-PB7
PCMSK0 |= (1 << PCINT2);
PCIFR |= (1 << PCIF0);
PCICR |= (1 << PCIE0);
// Initialize I2C slave
Wire.begin(I2C_ADDR);
Wire.onReceive(i2c_receive);
Wire.onRequest(i2c_request);
// Enable serial for debugging (optional)
Serial.begin(9600);
sei(); // Enable interrupts
}
// ===== Main Loop =====
void loop() {
// Check for bus timeout
if ((millis() - last_clk_time) > 1000) {
error_flags |= 0x40; // Frame timeout
}
// Poll memory keys (debounce: read every 20ms)
static unsigned long last_key_read = 0;
if (millis() - last_key_read > 20) {
last_key_read = millis();
uint8_t new_key_state = 0;
if (digitalRead(KEY_COMMON) == LOW) {
if (digitalRead(KEY_1) == LOW) new_key_state |= 0x01;
if (digitalRead(KEY_2) == LOW) new_key_state |= 0x02;
if (digitalRead(KEY_3) == LOW) new_key_state |= 0x04;
}
// Read UP/DOWN buttons (direct connections)
if (digitalRead(UP_BTN) == LOW) new_key_state |= 0x08; // Bit 3: UP
if (digitalRead(DOWN_BTN) == LOW) new_key_state |= 0x10; // Bit 4: DOWN
// Only update on state change
if (new_key_state != button_state) {
button_state = new_key_state;
// Optional: log key press to serial
// Serial.print("Button state: 0x"); Serial.println(button_state, HEX);
}
}
// Activity LED blinking (200ms toggle = visual indicator of protocol activity)
static unsigned long last_led_toggle = 0;
if (millis() - last_led_toggle > 200) {
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
last_led_toggle = millis();
}
}
7. Debugging & Troubleshooting
7.1 Common Issues
| Problem | Cause | Solution |
|---|---|---|
| I2C not responding | Address wrong or I2C not initialized | Check I2C address (0x50), verify Wire.h setup |
| LED not blinking | LED pin (PC1) not configured or LED polarity wrong | Verify pin 21 is configured as OUTPUT, check LED polarity (anode to PC1) |
| CLK interrupt never fires | Pin change interrupt not enabled | Check PCMSK0 |
| Display values always 0xFF | Protocol not being decoded | Check CLK/DIO connections, verify PCINT interrupt fires |
| I2C reads return 0xFF | Register not implemented | Ensure i2c_request() switch statement covers all register addresses |
| Memory keys not detected | KEY pins not connected or pull-ups not enabled | Verify KEY_COMMON/1/2/3 pull-ups enabled, check connections to display board |
| UP/DOWN buttons not working | Button pins (PC2/PC3) not configured or buttons not connected | Verify pins 24/25 are INPUT_PULLUP, check button wiring and polarity |
| Buttons register as always pressed | Pull-ups not working or button stuck | Check PORTC pull-up settings, verify buttons work mechanically |
| ATmega328P won't program via ISP | Fuses incorrect or ISP speed too fast | Reduce ISP speed, verify ISP header connections, use -B flag in avrdude |
7.2 Testing Checklist
- ATmega328P powers on and LED blinks (visual indicator)
- I2C responds at address 0x50 with correct VERSION (0x10)
- CLK pin shows activity on oscilloscope (protocol capture working)
- Registers 0x00-0x02 update when desk display shows new heights
- Register 0x03 updates with display brightness/status changes
- Register 0x04 (BTNS) reflects button state:
- Memory keys (bits 0-2) change when pressed via matrix
- UP button (bit 3) toggles when pressed
- DOWN button (bit 4) toggles when pressed
- Error flags (0x11) set when protocol bus inactive >1 second
- Shadow RAM test: After display blanks (sleep timeout), ESP32 still reads last height value
- Original desk display goes dark, but ESP32 I2C reads show "250" (not 0xFF)
- Display wakes up, shows "250" again
- Values persist across blanking events
- LED blinks continuously at ~2.5Hz (200ms toggle rate) during normal operation
- ISP programming works for firmware updates
- Serial debug output shows key presses (optional)
8. Hardware Bill of Materials
| Component | Value | Qty | Notes |
|---|---|---|---|
| ATmega328P | 28-pin QFN | 1 | Microchip ATmega328P (primary microcontroller) |
| LED | Any color (RED/GREEN) | 1 | Activity indicator (common cathode) |
| Resistor | 330Ω | 1 | LED current limiting (for 3.3V) |
| Resistor | 4.7k | 2 | I2C pull-ups (SCL/SDA to 3.3V) |
| Resistor | 10k | 2 | Optional: button pull-ups (if weak internal pull-ups needed) |
| Capacitor | 100nF | 4 | Decoupling (VCC), optional key input filtering |
| Button | Tactile 6mm | 2 | UP and DOWN buttons (momentary, normally open) |
| ISP Header | 6-pin | 1 | ISP programming connector (standard 2×3) |
| Serial Header | 6-pin FTDI | 1 | Optional: USB-UART debugging (PD0/PD1) |
| PCB | — | 1 | Breadboard prototype or custom PCB interposer |
Breadboard Prototype Cost: ~$3-5
Custom PCB Interposer Cost: ~$15-25 (including ISP header, serial header, resistors, capacitors)
9. Future Enhancements
- Handle memory button states via AiP650E key matrix
- Add CRC8 checksum for data integrity
- Implement watchdog timer for crash recovery
- Add EEPROM storage for statistics
- Support multiple display protocols (not just AiP650E)