Last active
May 27, 2025 08:15
-
-
Save dogtopus/7a25ba0763bc4a9c6e5251a74cb3ad78 to your computer and use it in GitHub Desktop.
Hitachi PC-LH series remote code decoder. Takes Flipper timing format.
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
#!/usr/bin/env python3 | |
import io | |
import itertools | |
KEYCODE_NAMES = { | |
0x01: 'POWER', | |
0x04: 'MODE', | |
0x05: 'FAN_SPEED', | |
0x08: 'TEMP_DOWN', | |
0x09: 'TEMP_UP', | |
0x0a: 'TEST', | |
0x0b: 'TIMER_SET', | |
0x0d: 'TIMER_RESET', | |
0x0e: 'SWING', | |
0x0f: 'FILTER_RESET', | |
} | |
FAN_SPEED_NAMES = { | |
0x1: 'Low', | |
0x2: 'Medium', | |
0x4: 'High', | |
} | |
MODE_NAMES = { | |
0x2: 'Fan', | |
0x4: 'Dehumidifying', | |
0x6: 'Cooling', | |
0x8: 'Heating', | |
0xb: 'Auto', | |
} | |
SWING_NAMES = { | |
0x0: '0', | |
0x2: '1', | |
0x4: '2', | |
0x6: '3', | |
0x8: '4', | |
0xa: '5', | |
0xc: '6', | |
0x1: 'Swing', | |
} | |
POWER_NAMES = { | |
0x9: 'ON', | |
0xa: 'OFF' | |
} | |
def checksum(data: bytes) -> str: | |
if all(d[0] ^ d[1] == 0xff for d in itertools.batched(data, 2)): | |
return '(OK)' | |
return '(NG)' | |
def simplify(data: bytes) -> str: | |
if data[:3] != b'\x01\x10\x00': | |
return | |
print('================') | |
print('Raw:', data[3::2].hex()) | |
def parse(data: bytes): | |
def parse_temp(dio: io.BytesIO): | |
temp_bytes = dio.read(2) | |
temp = 7 + (temp_bytes[0] >> 1) | |
print(f'Temperature: {temp_bytes[0]:#04x}', checksum(temp_bytes)) | |
# TODO is the last bit indicating the unit? | |
print(f' Decoded: {temp} degC') | |
def parse_fsm(dio: io.BytesIO): | |
fan_speed_mode_bytes = dio.read(2) | |
fan_speed = FAN_SPEED_NAMES.get(fan_speed_mode_bytes[0] >> 4, '?') | |
mode = MODE_NAMES.get(fan_speed_mode_bytes[0] & 0xf, '?') | |
print(f'Fan speed & mode: {fan_speed_mode_bytes[0]:#04x}', checksum(fan_speed_mode_bytes)) | |
print(f' Fan speed: {fan_speed}') | |
print(f' Mode: {mode}') | |
def parse_swing(dio: io.BytesIO): | |
swing_bytes = dio.read(2) | |
print(f'Swing: {swing_bytes[0]:#04x}', checksum(swing_bytes)) | |
print(f' Angle: {SWING_NAMES.get(swing_bytes[0], '?')}') | |
def parse_control(dio: io.BytesIO): | |
control_bytes = dio.read(2) | |
power = control_bytes[0] >> 4 | |
print(f'Control: {control_bytes[0]:#04x}', checksum(control_bytes)) | |
print(f' Power: {POWER_NAMES.get(power)}') | |
def parse_timer(dio: io.BytesIO): | |
timer_bytes = dio.read(6) | |
timer_raw = int.from_bytes(timer_bytes[::2], 'little') | |
timer_off = timer_raw & 0xfff | |
timer_on = timer_raw >> 12 | |
print(f'Timer: {timer_raw:#08x}', checksum(timer_bytes)) | |
print(f' Off timer: {timer_off} min') | |
print(f' On timer: {timer_on} min') | |
def parse_timer_test(dio: io.BytesIO): | |
timer_bytes = dio.read(4) | |
timer_raw = int.from_bytes(timer_bytes[::2], 'little') | |
timer_off = timer_raw & 0xfff | |
mode = MODE_NAMES.get(timer_raw >> 12, '?') | |
print(f'Timer & mode (test): {timer_raw:#06x}', checksum(timer_bytes)) | |
print(f' Off timer: {timer_off} min') | |
print(f' Mode: {mode}') | |
def parse_parameter_marker(dio: io.BytesIO): | |
parameter_marker_bytes = dio.read(2) | |
print(f'Parameter marker? (0x3f): {parameter_marker_bytes[0]:#04x}', checksum(parameter_marker_bytes)) | |
dio = io.BytesIO(data) | |
preamble = dio.read(3) | |
if preamble != b'\x01\x10\x00': | |
print('Invalid frame.') | |
return | |
print('----------------') | |
sync2_bytes = dio.read(4) | |
sync2 = int.from_bytes(sync2_bytes[::2], 'little') | |
print(f'SYNC 2 (0xff40): {sync2:#06x}', checksum(sync2_bytes)) | |
# If this is 0xcc, it could be an RAS series remote and not compatible with LH series remote | |
# https://perhof.wordpress.com/2015/03/29/reverse-engineering-hitachi-air-conditioner-infrared-remote-commands/ | |
size_bytes = dio.read(2) | |
type = 'UNKNOWN' | |
size = 0 | |
if size_bytes[0] == 0xcc: | |
type = 'RAS' | |
size = 0xf | |
return | |
elif size_bytes[0] & 0xf0 == 0xe0: | |
type = 'LH' | |
size = (size_bytes[0] & 0x0f) + 1 | |
else: | |
print(f'WARNING: Unknown remote type {size_bytes:#04x}.') | |
return | |
print(f'Size & type: {size_bytes[0]:#04x}', checksum(size_bytes)) | |
print(f' Type: {type}') | |
print(f' Size: {size}') | |
keycode_marker_bytes = dio.read(2) | |
print(f'Keycode marker? (0x89): {keycode_marker_bytes[0]:#04x}', checksum(keycode_marker_bytes)) | |
keycode_bytes = dio.read(2) | |
keycode = keycode_bytes[0] & 0x7f | |
side = keycode_bytes[0] >> 7 | |
print(f'Keycode: {keycode:#04x} ({KEYCODE_NAMES.get(keycode, '?')})', checksum(keycode_bytes)) | |
print(f' Side: {('A', 'B')[side]}') | |
if size <= 2: | |
return | |
if size == 9: | |
if keycode == 0x0b: | |
parse_parameter_marker(dio) | |
parse_temp(dio) | |
parse_timer(dio) | |
parse_fsm(dio) | |
parse_swing(dio) | |
elif size == 7: | |
if keycode == 0x01: | |
parse_parameter_marker(dio) | |
parse_temp(dio) | |
parse_fsm(dio) | |
parse_swing(dio) | |
parse_control(dio) | |
elif size == 6: | |
if keycode == 0x04: | |
parse_parameter_marker(dio) | |
parse_temp(dio) | |
parse_fsm(dio) | |
parse_swing(dio) | |
elif size == 5: | |
if keycode == 0x0a: | |
parse_temp(dio) | |
parse_timer_test(dio) | |
elif size == 4: | |
parse_parameter_marker(dio) | |
match keycode: | |
case 0x0e: | |
parse_swing(dio) | |
case 0x08 | 0x09: | |
parse_temp(dio) | |
case 0x05: | |
parse_fsm(dio) | |
elif size != 3: | |
print("WARNING: Don't know how to parse parameters.") | |
if __name__ == '__main__': | |
while True: | |
try: | |
a = input().rstrip() | |
if a == '': | |
break | |
except EOFError: | |
break | |
b = list(int(i) for i in a.split(' ')) | |
c = list(itertools.batched(b, 2)) | |
d = bytes(int(''.join('1' if i[1] / i[0] > 2.0 else '0' for i in reversed(j)), 2) for j in itertools.batched(c, 8)) | |
simplify(d) | |
parse(d) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment