Zum Inhalt

a7105_gm24g.tc

a7105_gm24g.tc — A7105 receiver template with datasheet-correct

Source on GitHub

// a7105_gm24g.tc — A7105 receiver template with datasheet-correct
// calibration sequence, plus a documented failed reverse-engineering
// attempt of the GaoMei (高美) GM2.4G ceiling lamp remote protocol.
//
// ============================================================================
// WHY THIS FILE EXISTS (READ BEFORE COPYING)
// ============================================================================
//
// The existing a7105_listen.tc / a7105_scan.tc do NOT do the IF/VCO/Bank
// calibration that A7105 datasheet §15.1 says is mandatory after every reset.
// They work for most A7105 traffic because the chip's default calibration
// state happens to be usable for many protocols, but anything that needs
// reliable RX on a specific frequency band (TX too) requires the full cal.
//
// This file implements the COMPLETE init + cal sequence from the datasheet,
// useful as a starting point for any A7105 receive/transmit project.
//
// ============================================================================
// GM2.4G CEILING LAMP — SOLVED 2026-07-27. THE CHIP IS NOT AN A7105.
// ============================================================================
//
// This file's A7105 receiver could NEVER have worked on that lamp, and the
// old explanation here ("A7105 clone with proprietary preamble/whitening")
// was wrong. The remote runs an **LT8900/LT8910**. Everything below is
// re-derived from the same capture that was already on disk
// (~/Desktop/gm24g.sr) — no new measurement was needed.
//
// See examples/lt8900_lamp.tc for the working transmitter template.
//
// ── WHAT THE OLD ANALYSIS GOT WRONG ────────────────────────────────────────
//
// The SPI capture was decoded on the RISING SCK edge. Correct is the FALLING
// edge. Every byte was therefore shifted by exactly one bit, which produced:
//   * register addresses with a phantom bit 7 (81 82 83 85 8B 8C 8D) that
//     looked like A7105 strobe commands but were plain writes underneath
//   * the "ID = A4 5E" in reg 0x06 — that value never existed
// Lesson: if decoded register addresses carry a stray high bit but ascend
// cleanly once you mask it off, change the sampling edge before concluding
// the protocol is exotic.
//
// The HackRF reading was also off: the lamp does NOT hop 61..66. See below.
//
// ── HOW THE CHIP WAS IDENTIFIED WITHOUT ANY MARKING ────────────────────────
//
// The part is sanded blank, so the init sequence was used as the fingerprint:
// 21 of the remote's init registers match `begin()` in the LT8900 Arduino
// library (github.com/MINI-Qiang/LT8910) byte for byte, zero deviations —
// 0x00=6FE0, 0x01=5681, 0x02=6617, 0x04=9CC9, 0x05=6637, 0x08=6C90,
// 0x0A=7FFD, 0x0D=48BD, 0x16=00FF, 0x17=8005, 0x18=0067, 0x19=1659,
// 0x1A=19E0, 0x1B=1300, 0x1C=1800, 0x21=3FC7, 0x22=2000, 0x2A=FDB0,
// 0x2B=000F, 0x34=8080, 0x0C=0000.
//
// That also explains the shape of the traffic: the LT8900 has 16-BIT
// registers (hence addr + 2 data bytes per transaction, which no A7105 does),
// its FIFO is register 50 = 0x32 (hence the 0x32 command byte on payload
// writes), and PACK_LEN_ENABLE puts a length byte first in the FIFO.
//
// ── PROTOCOL ───────────────────────────────────────────────────────────────
//
//   FIFO packet:  07  5A 4B 07 01  CC  VV  SUM
//                 │   └ device ID  └ grp │   └ SUM = sum of the 7 bytes & 0xFF
//                 └ length = 7 following bytes
//   Constant part sums to 0xB4, so SUM = (0xB4 + CC + VV) & 0xFF.
//   Verified against ALL 810 captured frames — 810/810 correct.
//
//   Per-frame TX sequence:
//     reg 0x07 <- 0x0000           idle
//     reg 0x34 <- 0x8080           reset FIFO pointer
//     reg 0x32 <- 8 payload bytes
//     reg 0x07 <- 0x0100 | chan    bit 8 = TX enable
//
//   FHSS: channels 4 / 39 / 74 (2406 / 2441 / 2476 MHz), round-robin,
//   1.135 ms apart, 90 transmissions per button press (30 per channel).
//
//   Packet config reg 0x29 = 0xB000 = CRC_ON | PACK_LEN_ENABLE | FW_TERM_TX.
//   SCRAMBLE is OFF — there is no whitening to reverse.
//
//   Sync word: the remote writes ONLY reg 36 (0x151A) and reg 39 (0x55AA);
//   regs 37/38 stay at reset default. Mirror that exactly and the sync word
//   matches without needing to know the defaults.
//
// ── CONFIRMED ON AIR (HackRF, 2026-07-28) ──────────────────────────────────
//   Everything above was derived from the SPI capture; it has since been
//   measured over the air and matches:
//     2441.02 MHz for channel 39  -> the 2402+n mapping is correct
//     1 Mbit/s, burst 182 us, preamble 0x55, on-air sync word 8A 85 5A AA,
//     payload bit-reversed. Device ID 5A 4B 07 identical to the SPI capture.
//     154 valid packets decoded in 19 s, every checksum correct.
//
//   BUTTON TABLE (one capture per button, 100-340 packets each):
//     CC = 0x10 flag | command nibble. The 0x10 bit only distinguishes first
//     press from repeat; the low nibble is the command:
//       4 = dimmer   5 = ON     9 = OFF
//       C = brighter E = cool   F = warm
//     The remote has exactly these six buttons (ON and OFF are separate).
//   VV is a counter that increments per press and wraps at 0xFF.
//   Byte 4 read 0x00 on air but 0x01 in the May SPI capture — meaning unknown,
//   apparently not critical.
//
//   Measuring recipe that worked (the first three attempts did not): hold the
//   remote 1-2 cm from the HackRF antenna and turn the gain DOWN
//   (-l 8 -g 12 -a 0). All three lamp frequencies sit inside the WiFi band;
//   against WiFi only near-field helps, no amount of signal processing.
//   Also discard the first second of every capture — the HackRF start-up
//   transient produces phantom bursts that look exactly like signal.
//
// ── STILL OPEN ─────────────────────────────────────────────────────────────
//   * Transmitting has never been tried — no LT8900 module on hand yet.
//     See lt8900_lamp.tc, which now carries the measured button table.
//
// ============================================================================
// USAGE
// ============================================================================
//
// Commands (after device boots with this script):
//   GM SCAN       → camp on ch 61..66, ~300ms each (the lamp's known band)
//   GM CH N       → camp on a single channel N (0..127)
//   GM REGS       → dump regs 0x00..0x1F to log
//   GM LOOSE      → switch ID match to ETH=110 (max permissive — catches noise too)
//   GM STRICT     → switch ID match to ETH=0   (exact match only)
//   GM OFF        → stop receiver
//
// Wiring (XL7105-SY-B 3-wire SPI module on ESP32-C3):
//   SCK  -> SPI CLK (bus 1)
//   MISO -> SDIO direct
//   MOSI -> SDIO via 1 kΩ resistor (3-wire fight-protection)
//   SCS  -> GPIO 3
//   GIO1 -> GPIO 1 (optional, for FSYNC indicator)

#define CS_PIN  3
#define SLOT    2

// A7105 strobe codes — top 4 bits = state, low 4 bits are don't-care
#define A7_STB_SLEEP     0x80
#define A7_STB_IDLE      0x90
#define A7_STB_STANDBY   0xA0
#define A7_STB_PLL       0xB0
#define A7_STB_RX        0xC0
#define A7_STB_TX        0xD0
#define A7_STB_RST_WRPTR 0xE0   // 1110xxxx per datasheet §10.4.7
#define A7_STB_RST_RDPTR 0xF0   // 1111xxxx per datasheet §10.4.8

char spi[20];
int  cur_ch = 63;
int  active = 0;
int  grabs  = 0;
int  scan_ch = 0;
int  scan_until = 0;

void a7_strobe(int s) {
    spi[0] = s & 0xFF;
    spiTransfer(SLOT, spi, 1, 1);
}

void a7_write(int addr, int val) {
    spi[0] = addr & 0x3F;        // bit 7=0 (control reg), bit 6=0 (write)
    spi[1] = val & 0xFF;
    spiTransfer(SLOT, spi, 2, 1);
}

int a7_read(int addr) {
    spi[0] = 0x40 | (addr & 0x3F);   // bit 6=1 (read)
    spi[1] = 0x00;
    spiTransfer(SLOT, spi, 2, 1);
    return spi[1] & 0xFF;
}

void a7_reset() {
    a7_write(0x00, 0x00);   // write 0x00 to MODE → soft reset → STANDBY
    delay(10);
}

// Write 4-byte ID in a SINGLE CS-low transaction per datasheet §10.6.1.
// IDDATA write pointer resets at CS-high, so all 4 bytes must be sent together.
void a7_write_id(int b0, int b1, int b2, int b3) {
    spi[0] = 0x06;
    spi[1] = b0 & 0xFF;
    spi[2] = b1 & 0xFF;
    spi[3] = b2 & 0xFF;
    spi[4] = b3 & 0xFF;
    spiTransfer(SLOT, spi, 5, 1);
}

// Full init + IF/VCO/Bank calibration per datasheet §15.1.
// Returns calibration time in ms (typically 1-3 ms; max 50 ms before timeout).
int a7_init() {
    a7_reset();

    // === Step 1: Init all control registers (datasheet §15.1 step 1) ===
    a7_write(0x01, 0x42);   // MODE_CTRL: ARSSI=1 (auto RSSI), FMS=1 (FIFO mode)
    a7_write(0x03, 0x0F);   // FIFO_I:    FEP=0x0F → 16-byte payload
    a7_write(0x0D, 0x05);   // CLOCK:     CSC=01 (/2), XS=1
    a7_write(0x0E, 0x04);   // DATA_RATE: SDR=4 → 100 kbps with 16 MHz xtal
                            //   formula: SDR = (Fxtal / target_bps / 32) - 1
                            //   for 16 MHz xtal: 100k → 4, 250k → 1, 500k → 0
    a7_write(0x10, 0x9E);   // PLL_II:    default for 2.4 GHz
    a7_write(0x11, 0x4B);   // PLL_III:   BIP=01001011
    a7_write(0x12, 0x00);   // PLL_IV
    a7_write(0x13, 0x02);   // PLL_V:     BFP
    a7_write(0x14, 0x16);   // TX_I:      FDP=110 → ~93 kHz deviation
    a7_write(0x15, 0x2B);   // TX_II:     PDV=01 FD=01011
    a7_write(0x16, 0x12);   // DELAY_I
    a7_write(0x17, 0x4A);   // DELAY_II:  WSEL=010 (600µs xtal settle)
    a7_write(0x18, 0x62);   // RX:        BWS=1 → 500 kHz IF bandwidth
    a7_write(0x19, 0x80);   // RX_GAIN_I: MVGS=1 (manual VGA)
    a7_write(0x1C, 0x0A);   // RX_GAIN_IV
    a7_write(0x1E, 0x32);   // ADC_CTRL:  RSM=00 (5 dBm), RSS=1
    a7_write(0x1F, 0x80);   // CODE_I:    IDL=0 (4-byte ID), PML[7:6]=10 (3B preamble)
    a7_write(0x20, 0xDF);   // CODE_II:   ETH=110 (3-bit ID tolerance — permissive
                            //              start; switch via GM STRICT later)
    a7_write(0x24, 0x13);   // VCO_CURRENT_CAL: MVCS=1 (manual), VCOC[3:0]=0011
                            //   per datasheet §15.3 step 3
    a7_write(0x25, 0x04);   // VCO_BAND_CAL_I:  MVBS=0 (auto), MVB[2:0]=100 (default)
    a7_write(0x26, 0x3B);   // VCO_BAND_CAL_II: VTH=111, VTL=011 (datasheet recommended)
    a7_write(0x29, 0x47);   // RX_DEM_TEST_I

    // === Set wildcard ID (most permissive — change for production use) ===
    // For a real protocol, replace with the actual 4-byte ID expected from
    // the transmitter. GM2.4G capture suggested A4 5E XX XX but couldn't be
    // verified working (see notes at top of file).
    a7_write_id(0xAA, 0xAA, 0xAA, 0xAA);

    // Set initial channel BEFORE calibration so VCO calibrates for the
    // correct frequency band
    a7_write(0x0F, 63);    // ch 63 = 2463 MHz (mid of lamp's hop range)

    // === Step 2..4: Calibration sequence (datasheet §15.1 step 2..4) ===
    a7_strobe(A7_STB_STANDBY);
    delay(2);
    a7_strobe(A7_STB_PLL);   // chip must be in PLL mode for VCO cal
    delay(2);

    // Enable IF Filter Bank (FBC=1), VCO Current (VCC=1), VCO Bank (VBC=1)
    // All three calibrations start simultaneously
    a7_write(0x02, 0x07);    // CAL_CTRL: bit2=VCC, bit1=VBC, bit0=FBC

    // Step 5..6: Poll for calibration done (FBC/VBC auto-clear; VCC stays set
    // when in manual mode MVCS=1, which is normal)
    int t0 = millis();
    int cal = 0xFF;
    while (millis() - t0 < 50) {
        cal = a7_read(0x02);
        // Auto-cal bits (FBC + VBC) cleared = IF + VCO bank done
        if ((cal & 0x03) == 0) break;
    }
    int cal_time = millis() - t0;

    // Step 6: check calibration pass/fail flags
    int r22 = a7_read(0x22);     // bit 4 = FBCF (0 = IF cal pass)
    int r25 = a7_read(0x25);     // bit 3 = VBCF (0 = VCO bank cal pass)
    int fbcf = (r22 >> 4) & 1;
    int vbcf = (r25 >> 3) & 1;
    if (fbcf != 0 || vbcf != 0) {
        addLog("a7105 CAL FAIL: FBCF=%d VBCF=%d (r22=%02X r25=%02X)",
               fbcf, vbcf, r22, r25);
    } else {
        addLog("a7105 cal OK in %dms (FBCF=0 VBCF=0)", cal_time);
    }

    // Back to standby — chip is now ready for RX or TX operations
    a7_strobe(A7_STB_STANDBY);
    delay(2);
    return cal_time;
}

void a7_rx_on(int ch) {
    a7_strobe(A7_STB_STANDBY);
    delay(1);
    a7_write(0x0F, ch & 0xFF);       // PLL_I = channel
    a7_strobe(A7_STB_PLL);
    delay(1);
    a7_strobe(A7_STB_RST_RDPTR);
    a7_strobe(A7_STB_RX);
    cur_ch = ch;
}

void drain_packet() {
    char pkt[16];
    a7_strobe(A7_STB_RST_RDPTR);
    int i = 0;
    while (i < 16) {
        spi[0] = 0x40 | 0x05;    // read FIFO_DATA (reg 0x05)
        spi[1] = 0x00;
        spiTransfer(SLOT, spi, 2, 1);
        pkt[i] = spi[1] & 0xFF;
        i = i + 1;
    }
    int crcf = (a7_read(0x00) >> 5) & 1;   // MODE bit 5 = CRC flag (0 = pass)
    char hex[140];
    // TinyC has no ternary expression for string args, so log crcf as integer
    sprintf(hex, "A7105 FRAME #%d ch=%d crcf=%d  %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X",
        grabs + 1, cur_ch, crcf,
        pkt[0] & 0xFF, pkt[1] & 0xFF, pkt[2] & 0xFF, pkt[3] & 0xFF,
        pkt[4] & 0xFF, pkt[5] & 0xFF, pkt[6] & 0xFF, pkt[7] & 0xFF,
        pkt[8] & 0xFF, pkt[9] & 0xFF, pkt[10] & 0xFF, pkt[11] & 0xFF,
        pkt[12] & 0xFF, pkt[13] & 0xFF, pkt[14] & 0xFF, pkt[15] & 0xFF);
    addLog(hex);
    grabs = grabs + 1;
}

void EveryLoop() {
    // Scan-mode channel rotation (every 300ms)
    if (scan_until > 0) {
        if (millis() > scan_until) {
            scan_ch = scan_ch + 1;
            if (scan_ch > 66) scan_ch = 61;
            scan_until = millis() + 300;
            a7_rx_on(scan_ch);
        }
    }
    if (active == 0) return;

    // Poll MODE register — when TRER (bit 0) goes low, a packet has arrived
    int m = a7_read(0x00);
    if ((m & 0x01) != 0) return;

    drain_packet();
    a7_strobe(A7_STB_RX);   // re-arm
}

void Command(char cmd[]) {
    char buf[100];
    if (strFind(cmd, "OFF") >= 0) {
        active = 0;
        scan_until = 0;
        a7_strobe(A7_STB_STANDBY);
        sprintf(buf, "A7105 off. frames=%d", grabs);
        responseCmnd(buf);
    } else if (strFind(cmd, "SCAN") >= 0) {
        scan_ch = 61;
        scan_until = millis() + 300;
        active = 1;
        grabs = 0;
        a7_rx_on(scan_ch);
        responseCmnd("A7105 SCAN started ch 61..66, 300ms each");
    } else if (strFind(cmd, "REGS") >= 0) {
        char line[80];
        int row = 0;
        while (row < 8) {
            int v0 = a7_read(row*4 + 0);
            int v1 = a7_read(row*4 + 1);
            int v2 = a7_read(row*4 + 2);
            int v3 = a7_read(row*4 + 3);
            sprintf(line, "reg[0x%02X..0x%02X] = %02X %02X %02X %02X",
                    row*4, row*4+3, v0, v1, v2, v3);
            addLog(line);
            row = row + 1;
        }
        responseCmnd("REGS dumped to log");
    } else if (strFind(cmd, "LOOSE") >= 0) {
        a7_strobe(A7_STB_STANDBY); delay(1);
        a7_write(0x20, 0xDF);
        responseCmnd("ETH=110 permissive (will catch noise)");
    } else if (strFind(cmd, "STRICT") >= 0) {
        a7_strobe(A7_STB_STANDBY); delay(1);
        a7_write(0x20, 0x07);
        responseCmnd("ETH=0 exact ID match only");
    } else if (strFind(cmd, "CH") >= 0) {
        char arg[8];
        strSub(arg, cmd, 3, 0);
        int ch = atoi(arg);
        if (ch < 0 || ch > 127) ch = 63;
        a7_rx_on(ch);
        active = 1;
        scan_until = 0;
        grabs = 0;
        sprintf(buf, "A7105 camping ch=%d", ch);
        responseCmnd(buf);
    } else {
        responseCmnd("Use: GM SCAN | GM CH <n> | GM REGS | GM LOOSE | GM STRICT | GM OFF");
    }
}

int main() {
    spiInit(-1, -1, -1, 1);
    spiSetCS(SLOT, CS_PIN);
    delay(100);
    a7_init();
    addCommand("GM");
    addLog("a7105_gm24g loaded. Use 'GM SCAN' / 'GM CH 63' / 'GM REGS'");
    return 0;
}