Skip to content

Instantly share code, notes, and snippets.

@DonKracho
Created February 16, 2026 16:40
Show Gist options
  • Select an option

  • Save DonKracho/45302e1e00f13109aecb713f56ab4543 to your computer and use it in GitHub Desktop.

Select an option

Save DonKracho/45302e1e00f13109aecb713f56ab4543 to your computer and use it in GitHub Desktop.
Python Script for creating HomeAssistant custom button cards for MOES UFO-R11 from NEC IR remote codes
Kerzen
Heller 0x807F1FE0
Dunkler 0x807F0AF5
Off 0x807F1EE1
On 0x807F12ED
Candle 0x807F07F8
Light 0x807F09F6
2H 0x807F01FE
4H 0x807F03FC
6H 0x807F04FB
8H 0x807F06F9
#!/usr/bin/env python3
import io
import base64
from bisect import bisect
from struct import pack, unpack
import os
def create_yaml_header(name):
return "type: vertical-stack\n\
cards:\n\
- type: custom:button-card\n\
color_type: label-card\n\
color: rgb(44, 109, 214)\n\
name: " + name
def create_yaml_horizontal_splitter():
return "\n\
- type: horizontal-stack\n\
cards:\
"
def create_yaml_button(name, code):
return "\n - type: custom:button-card\n\
name: " + name + "\n\
section_mode: true\n\
styles:\n\
card:\n\
- background-color: rgba(0, 0, 0, 0.1)\n\
tap_action:\n\
action: call-service\n\
service: mqtt.publish\n\
service_data:\n\
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set\n\
payload: \"{\\\"ir_code_to_send\\\":\\\"" + code + "\\\"}\""
def nec_hex_to_csv(hex_string):
if (hex_string[0:2] == "0x"): # remove "0x" preamble
hex_string = hex_string[2:]
ba = bytearray.fromhex(hex_string)
ba.reverse()
hex_value = int(ba.hex(), 16)
bit_mask = 0x80000000
csv = ",560,560"
while bit_mask != 0:
if hex_value & bit_mask:
csv = ",560,1680" + csv
else:
csv = ",560,560" + csv
bit_mask >>= 1
csv = "9000,4500" + csv
return csv
def decode_ir(code: str) -> list[int]:
payload = base64.decodebytes(code.encode('ascii'))
payload = decompress(io.BytesIO(payload))
signal = []
while payload:
assert len(payload) >= 2, f'garbage in decompressed payload: {payload.hex()}'
signal.append(unpack('<H', payload[:2])[0])
payload = payload[2:]
return signal
def encode_ir(signal: list[int], compression_level=2) -> str:
signal = [min(t, 65535) for t in signal] # clamp any timing over 65535
payload = b''.join(pack('<H', t) for t in signal)
compress(out := io.BytesIO(), payload, compression_level)
payload = out.getvalue()
return base64.encodebytes(payload).decode('ascii').replace('\n', '')
def decompress(inf: io.FileIO) -> bytes:
out = bytearray()
while (header := inf.read(1)):
L, D = header[0] >> 5, header[0] & 0b11111
if not L:
L = D + 1
data = inf.read(L)
assert len(data) == L
else:
if L == 7:
L += inf.read(1)[0]
L += 2
D = (D << 8 | inf.read(1)[0]) + 1
data = bytearray()
while len(data) < L:
data.extend(out[-D:][:L-len(data)])
out.extend(data)
return bytes(out)
def emit_literal_blocks(out: io.FileIO, data: bytes):
for i in range(0, len(data), 32):
emit_literal_block(out, data[i:i+32])
def emit_literal_block(out: io.FileIO, data: bytes):
length = len(data) - 1
assert 0 <= length < (1 << 5)
out.write(bytes([length]))
out.write(data)
def emit_distance_block(out: io.FileIO, length: int, distance: int):
distance -= 1
assert 0 <= distance < (1 << 13)
length -= 2
assert length > 0
block = bytearray()
if length >= 7:
assert length - 7 < (1 << 8)
block.append(length - 7)
length = 7
block.insert(0, length << 5 | distance >> 8)
block.append(distance & 0xFF)
out.write(block)
def compress(out: io.FileIO, data: bytes, level=2):
if level == 0:
return emit_literal_blocks(out, data)
W = 2**13
L = 255 + 9
distance_candidates = lambda: range(1, min(pos, W) + 1)
def find_length_for_distance(start: int) -> int:
length = 0
limit = min(L, len(data) - pos)
while length < limit and data[pos + length] == data[start + length]:
length += 1
return length
def find_length_max():
return max(
((find_length_for_distance(pos - d), d) for d in distance_candidates()),
key=lambda c: (c[0], -c[1]),
default=None
)
pos = 0
block_start = 0
while pos < len(data):
c = find_length_max()
if c and c[0] >= 3:
emit_literal_blocks(out, data[block_start:pos])
emit_distance_block(out, c[0], c[1])
pos += c[0]
block_start = pos
else:
pos += 1
emit_literal_blocks(out, data[block_start:pos])
if __name__ == "__main__":
print("Enter:\n'e' for Enncoding (Raw CSV Timing) or \n'd' for decoding (Tuya IR Code) or \n'h': for encoding NEC hex value\n'f' for converting file to homeassistant yaml")
mode = input("> ").strip().lower()
if mode == "e":
print("\nEnter the raw IR signal as a comma-separated list (e.g., 9000,4500,560,1690,...):")
raw_input_signal = input("> ")
try:
ir_signal = [int(x.strip()) for x in raw_input_signal.split(",")]
except ValueError:
print("Invalid input! Please enter only numbers separated by commas.")
exit(1)
tuya_code = encode_ir(ir_signal)
print("\nGenerated Tuya IR Code:")
print(tuya_code)
elif mode == "d":
print("\nEnter the Tuya IR Code to decode:")
tuya_input = input("> ").strip()
try:
decoded_signal = decode_ir(tuya_input)
print("\nDecoded Raw IR Signal (µs):")
print(decoded_signal)
except Exception as e:
print(f"Error decoding the Tuya IR Code: {e}")
elif mode == "h":
print("\nEnter the NEC 32 bit hex code e.g 00ff1ce3")
hex_input_signal = input("> ")
csv = nec_hex_to_csv(hex_input_signal)
print(csv)
try:
ir_signal = [int(x.strip()) for x in csv.split(",")]
except ValueError:
print("Invalid input! Please enter only numbers separated by commas.")
exit(1)
tuya_code = encode_ir(ir_signal)
print("\nGenerated Tuya IR Code:")
print(tuya_code)
elif mode == "f":
print("\nEnter the file path")
input_path = input("> ")
header_created = False
#input_path.replace('\\','/')
input_norm_path = os.path.normpath(input_path.replace('"',''))
with open(input_norm_path) as file:
with open(os.path.split(input_norm_path)[0] + "/py_generated.yaml", "w") as outfile:
for line in file:
x = line.rstrip().split('\t')
if len(x) == 2:
csv = nec_hex_to_csv(x[1])
try:
ir_signal = [int(x.strip()) for x in csv.split(',')]
except ValueError:
print("Invalid input! Please enter only numbers separated by commas.")
exit(1)
tuya_code = encode_ir(ir_signal)
line = create_yaml_button(x[0], tuya_code)
else:
if header_created:
line = create_yaml_horizontal_splitter()
else:
line = create_yaml_header(x[0])
header_created = True
print(line)
outfile.write(line)
else:
print("Invalid option. Please enter 'e' to encode or 'd' to decode.")
exit(1)
type: vertical-stack
cards:
- type: custom:button-card
color_type: label-card
color: rgb(44, 109, 214)
name: Kerzen
- type: horizontal-stack
cards:
- type: custom:button-card
name: Heller
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA+API+AXAeAFKw==\"}"
- type: custom:button-card
name: Dunkler
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA+ADJ+ADB+AHQ+AHB+AFQw==\"}"
- type: custom:button-card
name: Off
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA+APJ+AHQ+ATVwEwAg==\"}"
- type: custom:button-card
name: On
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA+ADJ+ALC+ADD+ADM+AFQw==\"}"
- type: horizontal-stack
cards:
- type: custom:button-card
name: Candle
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA+AHI+AXAeANQw==\"}"
- type: custom:button-card
name: Light
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA8Aj4AMv4BNH4AtTATAC\"}"
- type: horizontal-stack
cards:
- type: custom:button-card
name: 2H
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA8Aj4BcB4BVD\"}"
- type: custom:button-card
name: 4H
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA+ADI+AXAeARQw==\"}"
- type: custom:button-card
name: 6H
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA+AHK+ATQ+APTwEwAg==\"}"
- type: custom:button-card
name: 8H
section_mode: true
styles:
card:
- background-color: rgba(0, 0, 0, 0.1)
tap_action:
action: call-service
service: mqtt.publish
service_data:
topic: zigbee2mqtt/0xf0fd45fffef59bc5/set
payload: "{\"ir_code_to_send\":\"BSgjlBEwAuATAQGQBuAVA+AHJ+APQ+ATTwEwAg==\"}"
@DonKracho
Copy link
Author

DonKracho commented Feb 16, 2026

The cheap LCR-T7 component testers can decode at least NRC IR codes flawlessly.

Write down these codes in a textfile as "name [tab separaded] code" lines. Each line generates a button. An empty line creates a vertical splitter. The first line creates a header label.

Using the script with the text file as input will crate a yaml file for a HomeAssistant custom button card skeleton.

You will have to adapt the MQTT topic for your MOES UFO-R11 device!

With a litlle manual work you will get a nice remote card like this within homeassistant:
Screenshot 2026-02-16 175216

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment