Lircd2toml.py

#!/usr/bin/python3
#
# Copyright (C) 2018 Sean Young <sean@mess.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 2 of the License.
#
 
import sys
import os
import math
import argparse
 
class LircdParser:
    def __init__(self, filename, encoding, rename):
        self.lineno=0
        self.filename=filename
        self.encoding=encoding
        self.rename=rename
 
    def getline(self):
        while True:
            self.lineno += 1
            line = ""
            try:
                line = self.fh.readline();
            except IOError as e:
                self.error("reading file failed: {}".format(e))
            except UnicodeDecodeError as e:
                self.error("decoding file failed: {}".format(e))
            if line == "":
                return None
            line = line.strip()
            if len(line) == 0 or line[0] == '#':
                continue
            return line
 
    def warning(self, msg):
        print("{}:remote {}: warning: {}".format(self.filename, self.lineno, msg), file=sys.stderr)
 
    def error(self, msg):
        print("{}:remote {}: error: {}".format(self.filename, self.lineno, msg), file=sys.stderr)
 
    def parse(self):
        remotes = []
        try:
            self.fh=open(self.filename, encoding=self.encoding)
        except IOError as e:
            print("{}: error: {}".format(self.filename, e), file=sys.stderr)
            return remotes
 
        while True:
            line = self.getline()
            if line == None:
                break
            a = line.split(maxsplit=2)
            if a[0] != 'begin' or a[1] != 'remote':
                self.warning("expected 'begin remote', got '{} {}'".format(a[0], a[1]))
                continue
            if len(a) > 2 and a[2][0] != '#':
                self.error("unexpected {}".format(a[2]))
                return None
 
            remote = self.read_remote()
            if remote == None:
                return None
 
            remotes.append(remote)
 
        return remotes
 
    def read_remote(self):
        remote = {}
        while True:
            line = self.getline()
            if line == None:
                self.error("unexpected end of file")
                return None
 
            a = line.split()
            if len(a) < 2:
                self.error("expecting at least two keywords")
                return None
 
            if a[0] in ['name', 'driver', 'serial_mode']:
                remote[a[0]] = line.split(maxsplit=2)[1]
            elif a[0] in ['flags']:
                flags = []
                s=line.split(maxsplit=2)[1]
                for f in s.split(sep='|'):
                    flags.append(f.strip().lower())
                remote[ 'flags' ] = flags
            elif a[0] == 'begin':
                if a[1] == 'codes':
                    codes = self.read_codes()
                    if codes == None:
                        return None
                    remote['codes'] = codes
                elif a[1] == 'raw_codes':
                    codes = self.read_raw_codes()
                    if codes == None:
                        return None
                    remote['raw_codes'] = codes
                else:
                    self.error("{} unexpected".format(a[1]))
            elif a[0] == 'end':
                return remote
            else:
                k = a.pop(0)
                vals = []
                for v in a:
                    if v[0] == '#':
                        break
                    vals.append(int(v, 0))
                remote[k] = vals
 
    def read_codes(self):
        codes = {}
        while True:
            line = self.getline()
            if line == None:
                self.error("unexpected end of file")
                return None
 
            a = line.split()
            k = a.pop(0)
            if k == 'end':
                return codes
            if self.rename and not k.startswith('KEY_'):
                k = 'KEY_' + k.upper()
            for s in a:
                if s[0] == '#':
                    break
                scancode = int(s, 0)
                if scancode in codes:
                    self.warning("scancode 0x{:x} has duplicate definition {} and {}".format(scancode, codes[scancode], k))
 
                codes[scancode] = k
 
    def read_raw_codes(self):
        raw_codes = []
        codes = []
        name = ""
        while True:
            line = self.getline()
            if line == None:
                self.error("unexpected end of file")
                return None
 
            a = line.split()
            if a[0] == 'name':
                codes = raw_codes_sanitise(codes)
                if codes:
                    raw_codes.append({ 'keycode': name, 'raw': codes })
                name = line.split(maxsplit=2)[1]
                if self.rename and not name.startswith('KEY_'):
                    name = 'KEY_' + name.upper()
                codes = []
            elif a[0] == 'end':
                codes = raw_codes_sanitise(codes)
                if codes:
                    raw_codes.append({ 'keycode': name, 'raw': codes })
                return raw_codes
            else:
                for v in a:
                    codes.append(int(v))
 
def raw_codes_sanitise(codes):
    if len(codes) == 0:
        return None
    if len(codes) % 2 == 0:
        return codes[:-1]
 
    return codes
 
def eq_margin(duration, expected, margin):
    if duration >= (expected - margin) and duration <= (expected + margin):
        return True
    return False
 
def ffs(x):
    """Returns the index, counting from 0, of the
    least significant set bit in `x`.
    """
    return (x&-x).bit_length()-1
 
def rev8(v):
    r = 0
    for b in range(8):
        if v & (1 << (7 - b)):
            r |= 1 << b
    return r
 
def decode_nec_scancode(s):
    cmdc = rev8(s)
    cmd = rev8(s >> 8)
    addc = rev8(s >> 16)
    add = rev8(s >> 24)
 
    if cmd == (~cmdc & 0xff):
        if add == (~addc & 0xff):
            return 'nec', (add << 8) | cmd
        else:
            return 'necx', (add << 16) | (addc << 8) | cmd
    else:
        return 'nec32', (add << 24) | (addc << 16) | (cmd << 8) | cmdc
 
class Converter:
    def __init__(self, filename, remote):
        self.filename = filename
        self.remote = remote
 
    def error(self, msg):
        name = self.remote['name']
        print("{}:remote {}: error: {}".format(self.filename, name, msg), file=sys.stderr)
 
    def warning(self, msg):
        name = self.remote['name']
        print("{}:remote {}: warning: {}".format(self.filename, name, msg), file=sys.stderr)
 
    def convert(self):
        if 'driver' in self.remote:
            self.error("remote definition is for specific driver")
            return None
 
        if 'flags' not in self.remote:
            self.error("broken, missing flags parameter")
            return None
 
        flags = self.remote['flags']
 
        if 'rc5' in flags or 'shift_enc' in flags:
            return self.convert_rc5()
        elif 'rc6' in flags:
            return self.convert_rc6()
        elif 'rcmm' in flags:
            return self.convert_rcmm()
        elif 'space_enc' in flags:
            return self.convert_space_enc()
        elif 'raw_codes' in flags:
            return self.convert_raw_codes()
        else:
            self.error('Cannot convert remote with flags: {}'.format('|'.join(flags)))
 
        return None
 
    def convert_space_enc(self):
        if 'one' not in self.remote or 'zero' not in self.remote:
            self.error("broken, missing parameter for 'zero' and 'one'")
            return None
 
        res  = {
            'protocol': 'pulse_distance',
            'params': {},
            'scancodes': {}
        }
 
        res['name'] = self.remote['name']
 
        if 'bits' not in self.remote:
            self.error("broken, missing 'bits' parameter")
            return None
 
        bits = int(self.remote['bits'][0])
 
        if 'footer' in self.remote:
            self.warning("cannot deal with parameter 'footer' yet")
 
        if 'ptrail' in self.remote:
            res['params']['trailer_pulse'] = self.remote['ptrail'][0]
 
        if 'slead' in self.remote:
            self.warning("cannot deal with parameter 'slead' yet")
 
        if 'no_foot_rep' in self.remote['flags']:
            self.warning("cannot deal with flag 'no_foot_rep' yet")
 
        if 'repeat_header' in self.remote['flags']:
            self.warning("cannot deal with flag 'repeat_header' yet")
 
        if 'no_head_rep' in self.remote['flags']:
            res['params']['header_optional'] = 1
 
        if 'reverse' in self.remote['flags']:
            res['params']['reverse'] = 1
 
        if 'header' in self.remote and self.remote['header'][0]:
            res['params']['header_pulse'] = self.remote['header'][0]
            res['params']['header_space'] = self.remote['header'][1]
 
        if 'repeat' in self.remote and self.remote['repeat'][0]:
            res['params']['repeat_pulse'] = self.remote['repeat'][0]
            res['params']['repeat_space'] = self.remote['repeat'][1]
 
        post_data_bits = 0
        if 'post_data_bits' in self.remote:
            post_data_bits = self.remote['post_data_bits'][0]
 
        pre_data = 0
        if 'pre_data_bits' in self.remote:
            pre_data_bits = int(self.remote['pre_data_bits'][0])
            if 'pre_data' in self.remote:
                pre_data = self.remote['pre_data'][0] << (bits + post_data_bits)
            bits += pre_data_bits
 
        if post_data_bits > 0:
            if 'post_data' in self.remote:
                pre_data |= self.remote['post_data'][0]
            bits += post_data_bits
 
        res['params']['bits'] = bits
        if eq_margin(self.remote['zero'][0], self.remote['one'][0], 100):
            res['params']['bit_pulse'] = int(self.remote['zero'][0])
            res['params']['bit_1_space'] = int(self.remote['one'][1])
            res['params']['bit_0_space'] = int(self.remote['zero'][1])
        elif eq_margin(self.remote['zero'][1], self.remote['one'][1], 100):
            res['params']['bit_space'] = int(self.remote['zero'][1])
            res['params']['bit_1_pulse'] = int(self.remote['one'][0])
            res['params']['bit_0_pulse'] = int(self.remote['zero'][0])
            res['protocol'] = 'pulse_length'
        else:
            self.error("unexpected combination of 'zero' and 'one'")
            return None
 
        # Many of these will be NEC; detect and convert
        if ('header_pulse' in res['params'] and
            'header_space' in res['params'] and
            'reverse' not in res['params'] and
            'trailer_pulse' in res['params'] and
            'header_optional' not in res['params'] and
            'pulse_distance' == res['protocol'] and
            eq_margin(res['params']['header_pulse'], 9000, 1000) and
            eq_margin(res['params']['header_space'], 4500, 1000) and
            eq_margin(res['params']['bit_pulse'], 560, 300) and
            eq_margin(res['params']['bit_0_space'], 560, 300) and
            eq_margin(res['params']['bit_1_space'], 1680, 300) and
            eq_margin(res['params']['trailer_pulse'], 560, 300) and
            res['params']['bits'] == 32 and
            ('repeat_pulse' not in res['params'] or
             (eq_margin(res['params']['repeat_pulse'], 9000, 1000) and
              eq_margin(res['params']['repeat_space'], 2250, 1000)))):
            self.warning('remote looks exactly like NEC, converting')
            res['protocol'] = 'nec'
            res['params'] = {}
 
            variant = None
 
            for s in self.remote['codes']:
                p = (s<<post_data_bits)|pre_data
                v, n = decode_nec_scancode(p)
                if variant == None:
                    variant = v
                elif v != variant:
                    variant = ""
 
                res['scancodes'][n] = self.remote['codes'][s]
 
            if variant:
                res['params']['variant'] = "'" + variant + "'"
        elif ('header_pulse' in res['params'] and
            'header_space' in res['params'] and
            'reverse' not in res['params'] and
            'trailer_pulse' in res['params'] and
            'header_optional' not in res['params'] and
            'pulse_distance' == res['protocol'] and
            eq_margin(res['params']['header_pulse'], 9000, 1000) and
            eq_margin(res['params']['header_space'], 4500, 1000) and
            eq_margin(res['params']['bit_pulse'], 560, 300) and
            eq_margin(res['params']['bit_1_space'], 560, 300) and
            eq_margin(res['params']['bit_0_space'], 1680, 300) and
            eq_margin(res['params']['trailer_pulse'], 560, 300) and
            res['params']['bits'] == 32 and
            ('repeat_pulse' not in res['params'] or
             (eq_margin(res['params']['repeat_pulse'], 9000, 1000) and
              eq_margin(res['params']['repeat_space'], 2250, 1000)))):
            self.warning('remote looks exactly like NEC, converting')
            res['protocol'] = 'nec'
            res['params'] = {}
            # bit_0_space and bit_1_space have been swapped, scancode
            # will need to be inverted
 
            variant = None
 
            for s in self.remote['codes']:
                p = (s<<post_data_bits)|pre_data
                v, n = decode_nec_scancode(~p)
                if variant == None:
                    variant = v
                elif v != variant:
                    variant = ""
 
                res['scancodes'][n] = self.remote['codes'][s]
 
            if variant:
                res['params']['variant'] = "'" + variant + "'"
        else:
            for s in self.remote['codes']:
                p = (s<<post_data_bits)|pre_data
                res['scancodes'][p] = self.remote['codes'][s]
 
        return res
 
    def convert_rcmm(self):
        res  = {
            'protocol': 'rc-mm',
            'params': {},
            'scancodes': {}
        }
 
        res['name'] = self.remote['name']
 
        if 'bits' not in self.remote:
            self.error("broken, missing 'bits' parameter")
            return None
 
        bits = int(self.remote['bits'][0])
 
        toggle_bit = 0
        if 'toggle_bit_mask' in self.remote:
            toggle_bit = ffs(int(self.remote['toggle_bit_mask'][0]))
        if 'toggle_bit' in self.remote:
            toggle_bit = bits - int(self.remote['toggle_bit'][0])
 
        if 'codes' not in self.remote or len(self.remote['codes']) == 0:
            self.error("missing codes section")
            return None
 
        if 'pre_data_bits' in self.remote:
            pre_data_bits = int(self.remote['pre_data_bits'][0])
            pre_data = int(self.remote['pre_data'][0]) << bits
            bits += pre_data_bits
            for s in self.remote['codes']:
                res['scancodes'][s|pre_data] = self.remote['codes'][s]
        else:
            res['scancodes'] = self.remote['codes']
 
        res['params']['bits'] = bits
        res['params']['variant'] = "'rc-mm-" + str(bits) + "'"
 
        if toggle_bit > 0 and toggle_bit < bits:
            res['params']['toggle_bit'] = toggle_bit
 
        return res
 
    def convert_rc6(self):
        res = {
            'protocol': 'rc-6',
            'params': { },
            'scancodes': { }
        }
 
        res['name'] = self.remote['name']
 
        if 'codes' not in self.remote or len(self.remote['codes']) == 0:
            self.error("missing codes section")
            return None
 
        bits = int(self.remote['bits'][0])
 
        pre_data = 0
        if 'pre_data_bits' in self.remote:
            pre_data_bits = int(self.remote['pre_data_bits'][0])
            pre_data = int(self.remote['pre_data'][0]) << bits
            bits += pre_data_bits
 
        toggle_bit = 0
        if 'toggle_bit_mask' in self.remote:
            toggle_bit = ffs(int(self.remote['toggle_bit_mask'][0]))
        if 'toggle_bit' in self.remote:
            toggle_bit = bits - int(self.remote['toggle_bit'][0])
 
        mask = (1<<(bits-5))-1
        if toggle_bit >= 0 and toggle_bit < bits:
            res['params']['toggle_bit'] = toggle_bit
            mask &= ~(1<<toggle_bit)
 
        # lircd explicitly encoded the five leading bits (start, 3 mode
        # bits, toggle). rc-core does not, so we need to strip the first
        # five bits.
        bits -= 5
        vendor = 0
        res['params']['bits'] = bits
        for s in self.remote['codes']:
            # lircd inverts all the bits (not sure why), rc-core encoding
            # matches https://www.sbprojects.net/knowledge/ir/rc6.php
            d = ~(s|pre_data)&mask
            if bits == 32:
                vendor = d >> 16
            res['scancodes'][d] = self.remote['codes'][s]
 
        if bits == 16:
            res['params']['variant'] = "'rc-6-0'"
        elif bits == 20:
            res['params']['variant'] = "'rc-6-6a-20'"
        elif bits == 24:
            res['params']['variant'] = "'rc-6-6a-24'"
        elif bits == 32 and vendor != 0x800f:
            res['params']['variant'] = "'rc-6-6a-32'"
        elif bits == 32 and vendor == 0x800f:
            res['params']['variant'] = "'rc-6-mce'"
 
        return res
 
    def convert_rc5(self):
        if 'one' not in self.remote or 'zero' not in self.remote:
            self.error("broken, missing parameter for 'zero' and 'one'")
            return None
 
        res  = {
            'protocol': 'manchester',
            'params': { },
            'scancodes': { }
        }
 
        res['name'] = self.remote['name']
 
        if 'header' in self.remote and self.remote['header'][0]:
            res['params']['header_pulse'] = self.remote['header'][0]
            res['params']['header_space'] = self.remote['header'][1]
 
        if 'codes' not in self.remote or len(self.remote['codes']) == 0:
            self.error("missing codes section")
            return None
 
        bits = int(self.remote['bits'][0])
 
        pre_data = 0
        if 'pre_data_bits' in self.remote:
            pre_data_bits = int(self.remote['pre_data_bits'][0])
            pre_data = int(self.remote['pre_data'][0]) << bits
            bits += pre_data_bits
 
        toggle_bit = 0
        if 'toggle_bit_mask' in self.remote:
            toggle_bit = ffs(int(self.remote['toggle_bit_mask'][0]))
        if 'toggle_bit' in self.remote:
            toggle_bit = bits - int(self.remote['toggle_bit'][0])
 
        if 'plead' in self.remote:
            plead = self.remote['plead'][0]
            one_pulse = self.remote['one'][0]
            zero_pulse = self.remote['zero'][0]
            if eq_margin(plead, one_pulse, 200):
                res['params']['scancode_mask'] = 1 << bits
            elif not eq_margin(plead, one_pulse + zero_pulse, 200):
                self.error("plead has unexpected value")
                return None
            bits += 1
 
        if toggle_bit >= 0 and toggle_bit < bits:
            res['params']['toggle_bit'] = toggle_bit
 
        res['params']['bits'] = bits
        res['params']['zero_pulse'] = int(self.remote['zero'][0])
        res['params']['zero_space'] = int(self.remote['zero'][1])
        res['params']['one_pulse'] = int(self.remote['one'][0])
        res['params']['one_space'] = int(self.remote['one'][1])
 
        # Many of these will be regular RC5; detect this, and convert
        # the scancodes to ir-rc5-decoder.c format
        if ('header_pulse' not in res['params'] and
            'header_space' not in res['params'] and
            res['params']['bits'] == 14 and
            ('toggle_bit' in res['params'] and
             res['params']['toggle_bit'] == 11) and
            eq_margin(res['params']['one_pulse'], 888, 200) and
            eq_margin(res['params']['one_space'], 888, 200) and
            eq_margin(res['params']['zero_space'], 888, 200) and
            eq_margin(res['params']['zero_pulse'], 888, 200)):
            self.warning('remote looks exactly like regular RC-5, converting')
            res['params'] = {}
            res['protocol'] = 'rc5'
            newcodes = {}
            for s in self.remote['codes']:
                n = s|pre_data
                n = (n & 0x3f) | ((n << 2) & 0x1f00)
                newcodes[n] = self.remote['codes'][s]
            res['scancodes'] = newcodes
        else:
            for s in self.remote['codes']:
                res['scancodes'][s|pre_data] = self.remote['codes'][s]
 
        return res
 
    def convert_raw_codes(self):
        res  = {
            'protocol': 'raw',
            'params': {},
            'raw': self.remote['raw_codes'],
            'name': self.remote['name']
        }
 
        return res
 
def escapeString(s):
    return "'" + s.encode('unicode_escape').decode('utf-8') + "'"
 
def writeTOMLFile(fh, remote):
    print('[[protocols]]', file=fh)
    print('name = {}'.format(escapeString(remote['name'])), file=fh)
    print('protocol = {}'.format(escapeString(remote['protocol'])), file=fh)
    for p in remote['params']:
        print('{} = {}'.format(p, remote['params'][p]), file=fh)
 
    if 'scancodes' in remote:
        print('[protocols.scancodes]', file=fh)
        # find the largest scancode
        length=1
        for c in remote['scancodes']:
            length=max(length, c.bit_length())
 
        # width seems to include '0x', hence the + 2
        width = math.ceil(length/4) + 2
        for c in remote['scancodes']:
            print('{:#0{width}x} = {}'.format(c, escapeString(remote['scancodes'][c]), width=width), file=fh)
 
    elif 'raw' in remote:
        for raw in remote['raw']:
            print('[[protocols.raw]]', file=fh)
            print('keycode = {}\nraw = \''.format(escapeString(raw['keycode'])), file=fh, end='')
            for i, v in enumerate(raw['raw']):
                if i == 0:
                    print('+{}'.format(v), file=fh, end='')
                elif i % 2 == 1:
                    print(' -{}'.format(v), file=fh, end='')
                else:
                    print(' +{}'.format(v), file=fh, end='')
 
            print('\'', file=fh)
 
    return True
 
 
parser = argparse.ArgumentParser(description="""Convert lircd.conf to rc-core toml format.
This program atempts to convert a lircd.conf remote definition to a
ir-keytable toml format. This process is not perfect, and the result
might need some tweaks for it to work. Please report any issues to
linux-media@vger.kernel.org. If you have successfully generated and
tested a toml keymap, please send it to the same mailinglist so it
can be include with the package.""")
 
parser.add_argument('input', metavar='INPUT', help='lircd.conf file')
parser.add_argument('-o', '--output', metavar='OUTPUT', help='toml output file')
parser.add_argument('--encoding', default='utf-8-sig', help='Encoding of lircd.conf')
parser.add_argument('--preserve-codes', const=False, default=True,
    dest='rename', action='store_const', help='Do not rename codes to KEY_*')
 
args = parser.parse_args()
 
remoteNo=1
tomls=[]
remotes=LircdParser(args.input, args.encoding, args.rename).parse()
if remotes == None:
    sys.exit(1)
for remote in remotes:
    if 'name' not in remote:
        remote['name'] = 'remote_{}'.format(remoteNo)
        remoteNo += 1
    lircRemote = Converter(args.input, remote)
    tomlRemote = lircRemote.convert()
    if tomlRemote != None:
        tomls.append(tomlRemote)
 
if len(tomls) == 0:
    print("{}: error: no convertible remotes found".format(args.input), file=sys.stderr)
else:
    fh=sys.stdout
    if args.output:
        try:
            fh = open(args.output, 'w')
        except IOError as e:
            print("{}: error: {}".format(filename, e), file=sys.stderr)
            sys.exit(2)
    for t in tomls:
        writeTOMLFile(fh, t)