Created
June 15, 2026 13:46
-
-
Save 000hen/3f038318c31f00f7bc5bc130bcf9748f to your computer and use it in GitHub Desktop.
Sampo remote controller protocol
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * 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