Skip to content

Instantly share code, notes, and snippets.

@000hen
Created June 15, 2026 13:46
Show Gist options
  • Select an option

  • Save 000hen/3f038318c31f00f7bc5bc130bcf9748f to your computer and use it in GitHub Desktop.

Select an option

Save 000hen/3f038318c31f00f7bc5bc130bcf9748f to your computer and use it in GitHub Desktop.
Sampo remote controller protocol
/*
* ac_protocol.h
* -----------------------------------------------------------------------------
* Reverse-engineered IR protocol for a cheap split A/C remote.
* Fully derived in "Branch · 解碼冷氣IR訊號.pdf" by diffing recorded packets.
*
* This header records the *format only* (timings, byte layout, field encodings)
* plus pure helper functions to build/parse a frame. It has no Arduino
* dependency, so the same file is shared by the Arduino sketch and is mirrored
* (and self-tested) by verify.py.
*
* The remote turned out NOT to be a "full state" A/C protocol but a fixed
* 24-bit IR-encoder-IC style format: 3 payload bytes, each followed by its
* complement, MSB first. There are TWO frame types sharing the same IR timing.
*
* ============================ WIRE FORMAT ====================================
*
* Modulation : 38 kHz carrier. MARK = carrier on, SPACE = carrier off.
* Bit order : MSB first, within each of 6 bytes.
* Frame : HEADER, 48 data bits, FOOTER mark. Sent twice with a GAP
* between (a full capture was 199 timings = frame, gap, frame).
*
* Header : ~4400us mark , ~4400us space
* Bit 0 : ~550us mark , ~600us space
* Bit 1 : ~550us mark , ~1600us space
* Footer : ~550us mark (then GAP space, then repeat)
*
* ============================ FRAME TYPE 1: STATE ============================
*
* Byte | Meaning
* -----+------------------------------------------------------------
* B0 | Device ID = 0xB2 (fixed)
* B1 | Device ID = 0x4D (fixed)
* B2 | (fanCode << 5) | timerCode
* B3 | NOT(B2)
* B4 | (tempCode << 4) | modeCode | extra8hFlag
* B5 | timer ? ((B4 & 0x80) | 0x7F) : NOT(B4)
*
* B2 : top 3 bits = fan, low 5 bits = timer (0x1F when no timer).
* In AUTO and DRY modes the firmware ignores the fan setting and
* transmits fan bits = 000 (so B2 low part is 0x1F -> "1F E0").
* B4 : top nibble = temperature code (lookup, not linear -- a Gray/ROM code),
* bits 3..2 = mode, bit 0 = "+8 hours" timer flag.
* B5 : NOT(B4) in normal frames. In timer frames it is a flags byte, not a
* true complement -- captures fit (B4 & 0x80) | 0x7F (0x7F / 0xFF).
*
* ============================ FRAME TYPE 2: COMMAND ==========================
* Function keys (Turbo, Display, Self-clean, Mute, Swing) use a different,
* simpler frame -- a fixed prefix + command + its complement:
*
* B0=0xB5 B1=0x4A B2=0xF5 B3=0x0A B4=cmd B5=NOT(cmd)
*
* (Swing is a toggle COMMAND here, not a state bit. Sleep is not sent over IR.)
* -----------------------------------------------------------------------------
*/
#ifndef AC_PROTOCOL_H
#define AC_PROTOCOL_H
#include <stdint.h>
#include <stdbool.h>
/* ---------- IR timing (microseconds) ---------- */
#define AC_HDR_MARK 4400
#define AC_HDR_SPACE 4400
#define AC_BIT_MARK 550
#define AC_ONE_SPACE 1600
#define AC_ZERO_SPACE 600
#define AC_FOOTER_MARK 550
#define AC_GAP 5000
#define AC_FREQ_KHZ 38
#define AC_NBYTES 6
#define AC_NBITS 48
#define AC_MSB_FIRST true
/* Send the frame this many times per key press (header..footer repeated). */
#define AC_SEND_COUNT 2
/* ====================== STATE FRAME ====================== */
/* Fixed device ID. */
#define AC_DEV0 0xB2
#define AC_DEV1 0x4D
/* Fan codes occupy B2 bits 7..5. */
typedef enum {
AC_FAN_HIGH = 0x01, /* 001 -> B2 0x3F */
AC_FAN_MID = 0x02, /* 010 -> B2 0x5F */
AC_FAN_LOW = 0x04, /* 100 -> B2 0x9F */
AC_FAN_AUTO = 0x05 /* 101 -> B2 0xBF */
} AcFan;
/* Mode code occupies B4 bits 3..2 (value already shifted into place). */
typedef enum {
AC_MODE_COOL = 0x0,
AC_MODE_DRY = 0x4,
AC_MODE_AUTO = 0x8,
AC_MODE_HEAT = 0xC
} AcMode;
/* Temperature -> B4 high nibble. NOT linear: a hardware Gray/ROM-style code.
* Confirmed for the full 20..30 C range (see PDF "溫度編碼表 20~30°C"). */
#define AC_TEMP_MIN 20
#define AC_TEMP_MAX 30
static const uint8_t AC_TEMP_CODE[AC_TEMP_MAX - AC_TEMP_MIN + 1] = {
/* 20 */ 0x2,
/* 21 */ 0x6,
/* 22 */ 0x7,
/* 23 */ 0x5,
/* 24 */ 0x4,
/* 25 */ 0xC,
/* 26 */ 0xD,
/* 27 */ 0x9,
/* 28 */ 0x8,
/* 29 */ 0xA,
/* 30 */ 0xB
};
/* No-timer marker that fills B2's low 5 bits. */
#define AC_TIMER_NONE 0x1F
/* Max timer the remote encodes. */
#define AC_TIMER_MAX_HOURS 12
/* Known full-state OFF packet (a distinct sentinel state, B4 high nibble 0xE). */
static const uint8_t AC_OFF_FRAME[AC_NBYTES] = {
0xB2, 0x4D, 0x7B, 0x84, 0xE0, 0x1F
};
/* ====================== COMMAND FRAME ====================== */
#define AC_CMD0 0xB5
#define AC_CMD1 0x4A
#define AC_CMD2 0xF5
#define AC_CMD3 0x0A
typedef enum {
AC_CMD_MUTE = 0xB8,
AC_CMD_TURBO = 0xA2,
AC_CMD_DISPLAY = 0xA5,
AC_CMD_CLEANING = 0xAA,
AC_CMD_SWING = 0x6B
} AcCommand;
/* ============================================================================
* Helpers (pure, no I/O). Return true on success.
* ==========================================================================*/
/* Temperature -> high-nibble code. */
static inline bool ac_temp_code(uint8_t tempC, uint8_t *outCode) {
if (tempC < AC_TEMP_MIN || tempC > AC_TEMP_MAX) return false;
*outCode = AC_TEMP_CODE[tempC - AC_TEMP_MIN];
return true;
}
/* B2 low-5-bit timer code for a given hour count (1..12). */
static inline uint8_t ac_timer_code(uint8_t hours) {
/* 1..8 -> 0x03,0x07,...,0x1F ; 9..12 wrap and set the +8h flag in B4. */
return (uint8_t)((((hours - 1) & 0x07) << 2) | 0x03);
}
/*
* Build a STATE frame.
* power == false -> emits the captured OFF packet (args ignored).
* timerHours == 0 -> no timer.
* timerHours 1..12 -> shutdown timer.
* mode AUTO/DRY -> fan field is forced to 0 (matches firmware).
* Returns false on out-of-range temp or timer.
*/
static inline bool ac_build_state(bool power, AcFan fan, AcMode mode,
uint8_t tempC, uint8_t timerHours,
uint8_t out[AC_NBYTES]) {
if (!power) {
for (int i = 0; i < AC_NBYTES; i++) out[i] = AC_OFF_FRAME[i];
return true;
}
uint8_t tc;
if (!ac_temp_code(tempC, &tc)) return false;
if (timerHours > AC_TIMER_MAX_HOURS) return false;
bool timer = (timerHours >= 1);
uint8_t timerCode = timer ? ac_timer_code(timerHours) : AC_TIMER_NONE;
uint8_t extra8h = (timer && timerHours > 8) ? 0x01 : 0x00;
/* AUTO and DRY ignore the user's fan choice. */
uint8_t fanBits = (mode == AC_MODE_AUTO || mode == AC_MODE_DRY)
? 0x00 : (uint8_t)fan;
uint8_t b2 = (uint8_t)((fanBits << 5) | timerCode);
uint8_t b4 = (uint8_t)((tc << 4) | (uint8_t)mode | extra8h);
out[0] = AC_DEV0;
out[1] = AC_DEV1;
out[2] = b2;
out[3] = (uint8_t)~b2;
out[4] = b4;
out[5] = timer ? (uint8_t)((b4 & 0x80) | 0x7F) : (uint8_t)~b4;
return true;
}
/* Build a COMMAND (function-key) frame. */
static inline void ac_build_command(AcCommand cmd, uint8_t out[AC_NBYTES]) {
out[0] = AC_CMD0;
out[1] = AC_CMD1;
out[2] = AC_CMD2;
out[3] = AC_CMD3;
out[4] = (uint8_t)cmd;
out[5] = (uint8_t)~(uint8_t)cmd;
}
/* True if the frame is a command frame (B5 4A F5 0A ...). */
static inline bool ac_is_command(const uint8_t f[AC_NBYTES]) {
return f[0] == AC_CMD0 && f[1] == AC_CMD1 &&
f[2] == AC_CMD2 && f[3] == AC_CMD3 &&
f[5] == (uint8_t)~f[4];
}
/* True if the frame is a timer state frame (B5 is a flags byte, not ~B4). */
static inline bool ac_is_timer(const uint8_t f[AC_NBYTES]) {
return f[0] == AC_DEV0 && f[1] == AC_DEV1 &&
f[3] == (uint8_t)~f[2] &&
f[5] == (uint8_t)((f[4] & 0x80) | 0x7F) &&
f[5] != (uint8_t)~f[4];
}
/* Validate any known frame (device/command prefix + complement rules). */
static inline bool ac_frame_valid(const uint8_t f[AC_NBYTES]) {
if (ac_is_command(f)) return true;
if (f[0] != AC_DEV0 || f[1] != AC_DEV1) return false;
if (f[3] != (uint8_t)~f[2]) return false;
return f[5] == (uint8_t)~f[4] || /* normal */
f[5] == (uint8_t)((f[4] & 0x80) | 0x7F); /* timer */
}
#endif /* AC_PROTOCOL_H */
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment