Control a Heat & Glo fireplace via RF remote

But I need something for that [transmitter] to plug into. Could I use this, and plug this into the USB port of my HAOS machine?

You linked a ESP01 programmer, rather than an ESP01 board. You want to connect the transmitter into the ESP01 board itself. You can then program the ESP01 with the ESP01 programmer you linked. See the writeup here for ESP01 programming.

You could instead use a slightly more expensive ESP32 that you can plug directly into your computer to program or into a power brick once it’s all working. This is significantly easier to deal with.

(Note my links aren’t recommendations, just the first refs I saw on Amazon. Lots of options.)

Ok, I edited this post because I figured out the config stuff I needed to put before your code. It’s compiling and I now have the device in Home Assistant. I can click the buttons in the HA dashboard and my receive is seeing the codes but fireplace isn’t doing anything. Troubleshooting still on going…

edit: Looks like a few people have figured out there is some differences in the commands even in the same model. (GitHub discussion). I’m guessing that’s why my codes aren’t functioning. I can see the codes transmitting from ESPHome, and they are slightly different from the codes I see when using the remote. Maybe the reason the “program” function worked for some other above was that it changed the headers/messages until it matched up with your code

Some of the comments in that GitHub discussion you linked are mine and link to my fork of this code. I figured out most of the protocol and what all the bits do. I then made an esp8266 board similar to the poster on this forum thread did.

Here is my repo. I just added my esphome yaml and some other details to my repo. As I look at this now I kinda don’t like how it’s setup but I probably won’t change it anytime soon. It’s been stable for 5 years now as it is so I don’t wanna play with fire… metaphorically.

I’ll be giving this a try if I can get my Arduino working again. I think I bricked it, and it was acting as my 433 mhz receiver since it had a 5v pin. Thanks for linking

So I got my ESP32 recording some raw data on the 433 mhz receiver. The signals I captured are below after pressing the “Power On” button.

20:11:32.819 → *** RAW SIGNAL CAPTURED ***
20:11:32.819 → Pulse count: 200
20:11:32.819 → Timings (microseconds):
20:11:32.819 → 988, 1011, 978, 1004, 1020, 995, 995, 1011, 980, 1010,
20:11:32.819 → 1015, 984, 1011, 989, 1006, 982, 1019, 990, 1017, 974,
20:11:32.819 → 1013, 989, 1021, 981, 1013, 988, 1022, 978, 1012, 995,
20:11:32.819 → 1013, 982, 1018, 984, 1012, 979, 1019, 977, 1025, 979,
20:11:32.819 → 1022, 991, 1015, 976, 1017, 984, 1017, 983, 1011, 990,
20:11:32.851 → 1014, 982, 1022, 985, 1021, 975, 1022, 977, 1016, 990,
20:11:32.851 → 1015, 986, 1017, 984, 1006, 989, 1012, 982, 1025, 980,
20:11:32.851 → 1014, 990, 1005, 991, 1013, 984, 1014, 992, 1014, 977,
20:11:32.851 → 1021, 983, 1018, 977, 1020, 978, 1024, 982, 1020, 984,
20:11:32.851 → 1014, 977, 1019, 984, 1016, 980, 1030, 972, 1018, 994,
20:11:32.851 → 1022, 963, 1025, 980, 1023, 986, 1017, 982, 1008, 989,
20:11:32.851 → 1022, 976, 1020, 982, 1020, 982, 1013, 989, 1015, 977,
20:11:32.883 → 1026, 973, 1024, 983, 1009, 987, 1016, 979, 1037, 969,
20:11:32.883 → 1014, 989, 1017, 979, 1027, 969, 1029, 977, 1011, 986,
20:11:32.883 → 1029, 966, 1034, 976, 1020, 973, 1021, 987, 1009, 995,
20:11:32.883 → 1010, 981, 1020, 980, 1043, 951, 1024, 983, 1021, 986,
20:11:32.883 → 1009, 984, 1033, 967, 1019, 983, 1021, 974, 1024, 986,
20:11:32.883 → 1013, 980, 1021, 973, 1027, 975, 1020, 979, 1030, 971,
20:11:32.883 → 1026, 977, 1019, 976, 1030, 975, 1018, 985, 1035, 965,
20:11:32.916 → 1008, 989, 1016, 984, 1029, 964, 1033, 974, 1020, 984
20:11:32.916 →
20:11:32.916 →
20:11:32.916 → Signal Analysis:
20:11:32.916 → Shortest pulse: 951 μs
20:11:32.916 → Longest pulse: 1043 μs
20:11:32.916 → Ratio: 1.10

20:11:33.113 → *** RAW SIGNAL CAPTURED ***
20:11:33.113 → Pulse count: 117
20:11:33.113 → Timings (microseconds):
20:11:33.145 → 1101, 949, 1033, 969, 1041, 966, 1035, 957, 1042, 957,
20:11:33.145 → 1038, 963, 1032, 967, 1102, 380, 423, 378, 922, 384,
20:11:33.145 → 419, 1201, 426, 638, 924, 657, 424, 640, 923, 659,
20:11:33.145 → 421, 648, 421, 646, 422, 642, 922, 1466, 424, 645,
20:11:33.145 → 423, 641, 923, 655, 920, 656, 919, 660, 915, 662,
20:11:33.145 → 913, 668, 413, 1462, 416, 651, 418, 646, 918, 659,
20:11:33.145 → 916, 665, 415, 655, 415, 648, 915, 663, 912, 1475,
20:11:33.177 → 416, 653, 415, 652, 417, 652, 416, 652, 418, 650,
20:11:33.177 → 418, 649, 420, 644, 920, 1469, 421, 647, 421, 648,
20:11:33.177 → 421, 641, 923, 655, 920, 662, 417, 651, 419, 649,
20:11:33.177 → 419, 1451, 922, 655, 922, 655, 920, 662, 418, 651,
20:11:33.177 → 419, 645, 917, 660, 915, 662, 914
20:11:33.177 →
20:11:33.177 → Signal Analysis:
20:11:33.177 → Shortest pulse: 378 μs
20:11:33.177 → Longest pulse: 1475 μs
20:11:33.177 → Ratio: 3.90

20:11:33.275 → *** RAW SIGNAL CAPTURED ***
20:11:33.275 → Pulse count: 24
20:11:33.308 → Timings (microseconds):
20:11:33.308 → 664, 911, 671, 409, 660, 409, 659, 409, 1460, 913,
20:11:33.308 → 665, 912, 665, 910, 672, 409, 658, 409, 655, 910,
20:11:33.308 → 667, 908, 670, 906
20:11:33.308 →
20:11:33.308 → Signal Analysis:
20:11:33.308 → Shortest pulse: 409 μs
20:11:33.308 → Longest pulse: 1460 μs
20:11:33.308 → Ratio: 3.57

20:11:33.406 → *** RAW SIGNAL CAPTURED ***
20:11:33.406 → Pulse count: 84
20:11:33.406 → Timings (microseconds):
20:11:33.406 → 640, 428, 636, 929, 1460, 430, 638, 429, 635, 930,
20:11:33.406 → 647, 928, 651, 922, 655, 922, 655, 920, 663, 416,
20:11:33.438 → 1459, 419, 648, 421, 644, 920, 656, 919, 663, 416,
20:11:33.438 → 653, 417, 646, 919, 660, 913, 1474, 417, 651, 417,
20:11:33.438 → 651, 418, 651, 417, 650, 419, 650, 418, 650, 419,
20:11:33.438 → 644, 919, 1470, 420, 648, 421, 647, 421, 643, 920,
20:11:33.438 → 659, 916, 665, 416, 652, 416, 651, 418, 1454, 919,
20:11:33.438 → 658, 916, 662, 914, 669, 410, 657, 412, 652, 911,
20:11:33.438 → 667, 909, 669, 905
20:11:33.438 →
20:11:33.438 → Signal Analysis:
20:11:33.438 → Shortest pulse: 410 μs
20:11:33.471 → Longest pulse: 1474 μs
20:11:33.471 → Ratio: 3.60

20:11:33.603 → *** RAW SIGNAL CAPTURED ***
20:11:33.603 → Pulse count: 103
20:11:33.603 → Timings (microseconds):
20:11:33.635 → 957, 387, 418, 383, 915, 391, 414, 1204, 422, 642,
20:11:33.635 → 922, 660, 421, 643, 920, 661, 419, 650, 418, 649,
20:11:33.635 → 421, 643, 921, 1466, 425, 642, 426, 639, 924, 654,
20:11:33.635 → 921, 657, 919, 659, 916, 661, 915, 666, 414, 1460,
20:11:33.635 → 419, 649, 420, 645, 919, 657, 918, 664, 417, 651,
20:11:33.635 → 417, 648, 916, 661, 914, 1475, 416, 651, 416, 651,
20:11:33.635 → 419, 650, 418, 650, 419, 649, 420, 648, 420, 645,
20:11:33.635 → 918, 1470, 421, 646, 422, 648, 421, 642, 922, 656,
20:11:33.668 → 919, 663, 416, 651, 422, 647, 417, 1454, 920, 658,
20:11:33.668 → 916, 661, 914, 668, 413, 655, 413, 651, 913, 665,
20:11:33.668 → 910, 668, 907
20:11:33.668 →
20:11:33.668 → Signal Analysis:
20:11:33.668 → Shortest pulse: 383 μs
20:11:33.668 → Longest pulse: 1475 μs
20:11:33.668 → Ratio: 3.85

20:11:33.766 → *** RAW SIGNAL CAPTURED ***
20:11:33.766 → Pulse count: 35
20:11:33.766 → Timings (microseconds):
20:11:33.799 → 405, 662, 902, 1480, 409, 661, 408, 659, 409, 656,
20:11:33.799 → 910, 666, 908, 676, 404, 663, 407, 660, 408, 1463,
20:11:33.799 → 910, 667, 908, 670, 905, 678, 403, 664, 404, 660,
20:11:33.799 → 906, 671, 904, 673, 902
20:11:33.799 →
20:11:33.799 → Signal Analysis:
20:11:33.799 → Shortest pulse: 403 μs
20:11:33.799 → Longest pulse: 1480 μs
20:11:33.799 → Ratio: 3.67

20:11:33.930 → *** RAW SIGNAL CAPTURED ***
20:11:33.930 → Pulse count: 79
20:11:33.930 → Timings (microseconds):
20:11:33.930 → 402, 666, 404, 659, 904, 675, 900, 677, 898, 680,
20:11:33.930 → 896, 681, 894, 688, 391, 1483, 397, 670, 400, 664,
20:11:33.930 → 899, 679, 896, 684, 397, 672, 397, 666, 898, 681,
20:11:33.930 → 894, 1492, 399, 670, 399, 668, 401, 668, 400, 666,
20:11:33.930 → 403, 664, 406, 663, 405, 658, 907, 1481, 409, 658,
20:11:33.963 → 410, 659, 411, 652, 912, 665, 910, 672, 409, 658,
20:11:33.963 → 410, 657, 412, 1459, 915, 662, 914, 662, 913, 670,
20:11:33.963 → 411, 657, 412, 650, 915, 664, 911, 668, 908
20:11:33.963 →
20:11:33.963 → Signal Analysis:
20:11:33.963 → Shortest pulse: 391 μs
20:11:33.963 → Longest pulse: 1492 μs
20:11:33.963 → Ratio: 3.82

20:11:34.061 → *** RAW SIGNAL CAPTURED ***
20:11:34.061 → Pulse count: 76
20:11:34.061 → Timings (microseconds):
20:11:34.061 → 616, 947, 632, 942, 637, 937, 641, 934, 644, 929,
20:11:34.061 → 654, 426, 1449, 429, 639, 429, 635, 928, 652, 923,
20:11:34.093 → 659, 420, 649, 419, 646, 917, 660, 915, 1473, 415,
20:11:34.093 → 655, 414, 655, 414, 653, 415, 654, 415, 653, 415,
20:11:34.093 → 654, 414, 649, 915, 1474, 415, 655, 414, 653, 414,
20:11:34.093 → 651, 912, 667, 908, 674, 405, 663, 407, 662, 405,
20:11:34.093 → 1465, 910, 668, 905, 673, 902, 681, 399, 670, 398,
20:11:34.093 → 665, 899, 680, 895, 683, 892
20:11:34.093 →
20:11:34.093 → Signal Analysis:
20:11:34.093 → Shortest pulse: 398 μs
20:11:34.093 → Longest pulse: 1474 μs
20:11:34.093 → Ratio: 3.70

20:11:34.224 → *** RAW SIGNAL CAPTURED ***
20:11:34.224 → Pulse count: 104
20:11:34.224 → Timings (microseconds):
20:11:34.224 → 771, 998, 336, 465, 337, 960, 348, 453, 1168, 456,
20:11:34.224 → 609, 953, 631, 447, 618, 944, 638, 441, 629, 439,
20:11:34.256 → 631, 439, 625, 936, 1454, 435, 634, 432, 634, 929,
20:11:34.256 → 650, 926, 651, 923, 657, 918, 658, 917, 666, 414,
20:11:34.256 → 1460, 420, 647, 419, 645, 920, 657, 919, 661, 420,
20:11:34.256 → 649, 419, 643, 922, 654, 923, 1464, 428, 640, 429,
20:11:34.256 → 636, 434, 632, 438, 631, 437, 630, 440, 627, 442,
20:11:34.256 → 620, 945, 1442, 451, 615, 455, 613, 455, 610, 956,
20:11:34.256 → 621, 955, 625, 455, 611, 458, 609, 459, 1412, 966,
20:11:34.289 → 610, 964, 613, 952, 653, 415, 651, 418, 644, 923,
20:11:34.289 → 654, 922, 653, 926
20:11:34.289 →
20:11:34.289 → Signal Analysis:
20:11:34.289 → Shortest pulse: 336 μs
20:11:34.289 → Longest pulse: 1464 μs
20:11:34.289 → Ratio: 4.36

20:11:34.453 → *** RAW SIGNAL CAPTURED ***
20:11:34.453 → Pulse count: 86
20:11:34.453 → Timings (microseconds):
20:11:34.453 → 632, 438, 629, 438, 627, 936, 1453, 436, 632, 436,
20:11:34.453 → 629, 935, 644, 931, 646, 929, 651, 923, 654, 919,
20:11:34.453 → 663, 418, 1457, 421, 648, 421, 643, 920, 658, 917,
20:11:34.453 → 665, 416, 653, 415, 649, 914, 663, 912, 1478, 412,
20:11:34.453 → 655, 414, 654, 414, 654, 415, 653, 415, 654, 416,
20:11:34.486 → 651, 417, 647, 917, 1472, 418, 650, 419, 648, 420,
20:11:34.486 → 645, 918, 660, 917, 665, 415, 652, 415, 654, 415,
20:11:34.486 → 1455, 920, 657, 918, 661, 914, 667, 414, 653, 415,
20:11:34.486 → 650, 913, 664, 911, 668, 908
20:11:34.486 →
20:11:34.486 → Signal Analysis:
20:11:34.486 → Shortest pulse: 412 μs
20:11:34.486 → Longest pulse: 1478 μs
20:11:34.486 → Ratio: 3.59

Hey, I got my stuff working by detecting my remote code and then emulating it. Would you be interested in the code that got it working and the codes I have?

Absolutely, please share, will undoubtedly be helpful for the next guy. Plus I’m curious what changed.

So I’m not a programmer. Other than taking a programming 101 course as a Freshman in college and some simple html/java stuff in the 2000s, I really only understand basic programming constructs. I used Claude so this might be non-sense and difficult to interpret, but I don’t have time to learn all the coding. I pointed it at Ardufuego github to have it understand the protocol and then collected some data for it to adjust the code to my remote. I’m still working on getting it into yaml/ESPHome. But for now, this worked great inside the Arduino IDE

/*
 * ESP32 433MHz Fireplace Transmitter
 * Based on Ardufuego/FireplaceRF implementation
 * 
 * Hardware: 433MHz Transmitter DATA -> GPIO 21, VCC -> 5V, GND -> GND
 */

const int TX_PIN = 21;

// YOUR FIREPLACE SETTINGS (captured from receiver)
const uint8_t START1 = 0x51;
const uint8_t START2 = 0x3E;
const uint8_t START3 = 0x33;
const uint8_t FUNCTION2_OFFSET = 0x18;  // Calculated from your captures

// Protocol timing (from Ardufuego)
const uint16_t VALUE_1_LENGTH = 880;      // HIGH pulse for bit "1"
const uint16_t VALUE_0_LENGTH = 380;      // HIGH pulse for bit "0"
const uint16_t BIT_PAUSE_HEADER = 420;    // LOW after each header bit
const uint16_t MESSAGE_PAUSE_HEADER = 830; // Extra pause after header
const uint16_t NUMBER_BITS_HEADER = 4;
const uint16_t BIT_PAUSE_BODY = 700;      // LOW after each body bit
const uint16_t MESSAGE_PAUSE_BODY = 820;  // Extra pause after body byte
const uint32_t MESSAGE_PAUSE = 90900;     // Pause between command repeats
const uint16_t MESSAGE_REPEAT = 10;       // Repeat each command 10 times
const uint8_t NUMBER_BITS_BODY = 8;
const uint16_t HEADER = 0b1010;

void setup() {
  Serial.begin(115200);
  delay(1000);
  
  Serial.println("\n=== ESP32 Fireplace Transmitter ===");
  Serial.println("Based on Ardufuego protocol");
  Serial.print("TX Pin: GPIO ");
  Serial.println(TX_PIN);
  Serial.print("Your Function2 Offset: 0x");
  Serial.println(FUNCTION2_OFFSET, HEX);
  
  pinMode(TX_PIN, OUTPUT);
  digitalWrite(TX_PIN, LOW);
  
  Serial.println("\nCommands:");
  Serial.println("  1 - Power ON");
  Serial.println("  2 - Power OFF");
  Serial.println("  f0-f5 - Flame (0=off, 1-5=height)");
  Serial.println("  s0-s3 - Fan speed 0-3");
  Serial.println();
}

void transmit(uint8_t message, uint8_t messageSize, uint16_t bitDelay, uint16_t endDelay) {
  for (uint8_t i = 1; i <= messageSize; i++) {
    uint8_t value = (message & (0x1 << (messageSize - i))) == 0 ? 0x0 : 0x1;
    
    // Send HIGH pulse
    digitalWrite(TX_PIN, HIGH);
    delayMicroseconds(value > 0 ? VALUE_1_LENGTH : VALUE_0_LENGTH);
    
    // Send LOW pulse
    digitalWrite(TX_PIN, LOW);
    delayMicroseconds(bitDelay);
  }
  
  // Additional pause after message
  delayMicroseconds(endDelay);
}

void transmitCommand(uint8_t command, uint8_t commandEnc) {
  for (uint16_t x = 1; x <= MESSAGE_REPEAT; x++) {
    noInterrupts();
    
    // Send header
    transmit(HEADER, NUMBER_BITS_HEADER, BIT_PAUSE_HEADER, MESSAGE_PAUSE_HEADER);
    
    // Send 3 start messages
    transmit(START1, NUMBER_BITS_BODY, BIT_PAUSE_BODY, MESSAGE_PAUSE_BODY);
    transmit(START2, NUMBER_BITS_BODY, BIT_PAUSE_BODY, MESSAGE_PAUSE_BODY);
    transmit(START3, NUMBER_BITS_BODY, BIT_PAUSE_BODY, MESSAGE_PAUSE_BODY);
    
    // Send 3 function messages
    transmit(command, NUMBER_BITS_BODY, BIT_PAUSE_BODY, MESSAGE_PAUSE_BODY);
    transmit(commandEnc, NUMBER_BITS_BODY, BIT_PAUSE_BODY, MESSAGE_PAUSE_BODY);
    transmit(~commandEnc, NUMBER_BITS_BODY, BIT_PAUSE_BODY, MESSAGE_PAUSE_BODY);
    
    interrupts();
    
    // Pause between command repetitions
    if (x < MESSAGE_REPEAT) {
      uint32_t remainingDelay = MESSAGE_PAUSE;
      while (remainingDelay > 16383) {
        delayMicroseconds(16383);
        remainingDelay -= 16383;
      }
      delayMicroseconds(remainingDelay);
    }
  }
}

void sendCommand(uint8_t commandId) {
  // Calculate commandEnc using YOUR offset
  uint8_t commandEnc = (commandId + FUNCTION2_OFFSET - 1) % 256;
  
  Serial.print("\nSending: 0x");
  if (commandId < 16) Serial.print("0");
  Serial.print(commandId, HEX);
  Serial.print(" | Function2: 0x");
  if (commandEnc < 16) Serial.print("0");
  Serial.print(commandEnc, HEX);
  Serial.print(" | Function3: 0x");
  uint8_t function3 = ~commandEnc;
  if (function3 < 16) Serial.print("0");
  Serial.println(function3, HEX);
  
  transmitCommand(commandId, commandEnc);
  
  Serial.println("Sent!");
}

void powerOn() {
  sendCommand(0x01);
}

void powerOff() {
  sendCommand(0x02);
}

void setFlame(uint8_t level) {
  if (level > 5) {
    Serial.println("Flame level must be 0-5 (0=off, 1-5=height)");
    return;
  }
  sendCommand(0x30 | level);  // 0x30-0x35
}

void setFan(uint8_t speed) {
  if (speed > 3) {
    Serial.println("Fan speed must be 0-3");
    return;
  }
  sendCommand(0x40 | speed);  // 0x40-0x43
}

void loop() {
  if (Serial.available()) {
    String input = Serial.readStringUntil('\n');
    input.trim();
    
    if (input == "1") {
      Serial.println(">>> Power ON");
      powerOn();
    }
    else if (input == "2") {
      Serial.println(">>> Power OFF");
      powerOff();
    }
    else if (input.startsWith("f")) {
      int level = input.substring(1).toInt();
      Serial.print(">>> Flame Level: ");
      Serial.println(level);
      setFlame(level);
    }
    else if (input.startsWith("s")) {
      int speed = input.substring(1).toInt();
      Serial.print(">>> Fan Speed: ");
      Serial.println(speed);
      setFan(speed);
    }
    else {
      Serial.println("Unknown command");
    }
  }
}

That’s really interesting. Timings are roughly the same. Looks like your 3 start bytes differ (mine F4E133, yours 513E33). Then there’s this FUNCTION2_OFFSET, that also apparently changes per remote, that is used to calculate the codes arrays I hard-coded for my fan/flame/light levels. I should incorporate that into my code to make it more universal.
I’m also impressed that you used AI to get the customization for your remote. A great use of AI!

Yeah. It was kinda fun doing it. I wish I had the knowhow to do it properly. It was a really fun moment when I sent that first signal and the fireplace kicked on.

Major props to you and P6mYem1ujcC8zNwR95 for figuring out the protocol, that really made the AI hone in on how to structure the command sequences

1 Like

I updated the yaml in the original post to easily enable customized header+offset in the substitutions section. It’s more universal now, but you’ll still have to figure out your remote customizations yourself. See the “edit” note at the bottom of the original post.

1 Like

The information here was super helpful, but I spent a little more time on this than I would be proud to admit. That said, I wanted to provide some info to hopefully shorten the curve for the next person.

First thing is, my recommendation is to not even waste your time trying to capture the codes/timing with another 433mhz receiver through HA. I got a lot of garbage data that had me fumbling around until I just captured the signal with an RTL-SDR and Universal Radio Hacker, which instantly gave me the clean bitstream that I could decode into binary. The 433mhz receiver via an ESP on HA was picking up a lot of other 433mhz noise making it hard to decode the bitstream from the remote.

From there I was able to easily decipher the code:

[Header] [Start1] [Start2] [Start3] [Function1] [Function1] [Function1]
1010 10110001 01010110 00110011 00000001 10010000 01101111
B1 56 33 01 90 6F

So I’ve got 0xB15633 as my start1, start2, and already known and fixed start3.

That leaves the offset to calculate. We know that the command code for power on is 01 and function2 is the command + offset, so we can calculate the offset from the result (0x90):

0x90 = 144 (decimal)

  • 0x01 = 1 (decimal)
    = 143 = 0x8F

These values all work perfectly to control my fireplace. Unfortunately I don’t think there is a way to determine this without capturing the remote code.

I do think that if you didn’t care about the remote working, you could choose any values, and then “pair” the receiver to that code. Basically I think what’s going on is each RC300 randomly generates its own seed, and then the receiver learns to accept that code. I haven’t tried it, but I bet it works!

 # See https://github.com/wQQhce2g93Ei/Ardufuego for details on figuring out your values for the header. (Note offset there is off by one from my definition.)
 # Capturing the header is sufficient:
 # byte1 = seed
 # byte2 = seed - 0x13
 # byte3 = 0x33
 # offset = (2 x seed + 0x75) % 0xFF

I could be wrong, but don’t think that these calculations are entirely accurate. My byte2 is not byte1 - 0x13. Calculating these values doesn’t give me the same result, and more importantly it doesn’t work. Your mileage may vary.

Hope this is helpful to someone!

Darn. I had 3 examples, where byte2 was 0x13 less than byte1, so I assumed that was universal. You have disproved me.
The calculation for offset needs to be adjusted assuming the first two are not related:
offset = (seed1 + seed2 + 0x88) % 0xFF
which still works in your case:
(0xB1 + 0x56 + 0x88) % 0xFF = 0x8F
I’ll fix the original post to help the next guy; sorry for the misdirect.

No worries at all, and I didn’t mean this to be a criticism, just hoping to make it easier for someone else. In any case, I don’t think you can get away from having to capture the RF from the remote. Maybe the best we could do is write a receiver that knows exactly what pattern to look for and output the codes, rather than the raw timings. That might be a fun little adventure actually.