Created
February 16, 2026 16:40
-
-
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
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
| Kerzen | |
| Heller 0x807F1FE0 | |
| Dunkler 0x807F0AF5 | |
| Off 0x807F1EE1 | |
| On 0x807F12ED | |
| Candle 0x807F07F8 | |
| Light 0x807F09F6 | |
| 2H 0x807F01FE | |
| 4H 0x807F03FC | |
| 6H 0x807F04FB | |
| 8H 0x807F06F9 |
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 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) |
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
| 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==\"}" |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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:
