diff mbox

[v3,5/5] ir-keytable: add tool to aid migration from lircd

Message ID f8e4c81ef460b544ec3f51de2680d68d88f40c98.1531491416.git.sean@mess.org (mailing list archive)
State New, archived
Headers show

Commit Message

Sean Young July 13, 2018, 2:30 p.m. UTC
This python script is written to help users translate their lircd.conf
remote definition to ir-keytable toml format. The tool does not cover
every possible lircd.conf, but the majority should work.

	./lircd2toml.py PD-202.lircd.conf -o PD-202.toml

Signed-off-by: Sean Young <sean@mess.org>
---
 contrib/Makefile.am   |   3 +-
 contrib/lircd2toml.py | 527 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 529 insertions(+), 1 deletion(-)
 create mode 100755 contrib/lircd2toml.py
diff mbox

Patch

diff --git a/contrib/Makefile.am b/contrib/Makefile.am
index d41dc539..db572c54 100644
--- a/contrib/Makefile.am
+++ b/contrib/Makefile.am
@@ -25,4 +25,5 @@  EXTRA_DIST = \
 	cobalt-ctl \
 	parsers \
 	pci_traffic \
-	v4l_rec.pl
+	v4l_rec.pl \
+	lircd2toml.py
diff --git a/contrib/lircd2toml.py b/contrib/lircd2toml.py
new file mode 100755
index 00000000..15cca3dd
--- /dev/null
+++ b/contrib/lircd2toml.py
@@ -0,0 +1,527 @@ 
+#!/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):
+        self.lineno=0
+        self.filename=filename
+        self.encoding=encoding
+
+    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.error("expected 'begin remote', got '{} {}'".format(a[0], a[1]))
+                return None
+            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 not k.startswith('KEY_'):
+                k = 'KEY_' + k.upper()
+            for s in a:
+                if s[0] == '#':
+                    break
+                codes[int(s, 0)] = 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':
+                if len(codes) > 0:
+                    raw_codes.append({ 'name': name, 'codes': codes })
+                name = line.split(maxsplit=2)[1]
+                if not name.startswith('KEY_'):
+                    name = 'KEY_' + name.upper()
+                codes = []
+            elif a[0] == 'end':
+                if len(codes) > 0:
+                    raw_codes.append({ 'name': name, 'codes': codes })
+                return raw_codes
+            else:
+                for v in a:
+                    codes.append(int(v))
+
+
+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 'rcmm' in flags:
+            return self.convert_rcmm()
+        elif 'space_enc' in flags:
+            return self.convert_space_enc()
+        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': {},
+            'map': {}
+        }
+
+        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['map'][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['map'][p] = self.remote['codes'][s]
+
+        return res
+
+    def convert_rcmm(self):
+        res  = {
+            'protocol': 'rc_mm',
+            'params': {},
+            'map': {}
+        }
+
+        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 toggle_bit > 0 and toggle_bit < bits:
+            res['params']['toggle_bit'] = toggle_bit
+
+        res['params']['bits'] = bits
+
+        if 'codes' not in self.remote or len(self.remote['codes']) == 0:
+            self.error("missing codes section")
+            return None
+
+        res['map'] = self.remote['codes']
+
+        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': { },
+            'map': { }
+        }
+
+        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])
+
+        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 toggle_bit >= 0 and toggle_bit < bits:
+            res['params']['toggle_bit'] = toggle_bit
+
+        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
+
+        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
+
+        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']:
+                s |= pre_data
+                n = (s & 0x3f) | ((s << 2) & 0x1f00)
+                newcodes[n] = self.remote['codes'][s]
+            res['map'] = newcodes
+        else:
+            for s in self.remote['codes']:
+                res['map'][s|pre_data] = self.remote['codes'][s]
+
+        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)
+    print('[protocols.scancodes]', file=fh)
+    # find the largest scancode
+    length=1
+    for c in remote['map']:
+        length=max(length, c.bit_length())
+
+    # width seems to include '0x', hence the + 2
+    width = math.ceil(length/4) + 2
+    for c in remote['map']:
+        print('{:#0{width}x} = {}'.format(c, escapeString(remote['map'][c]), width=width), 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')
+
+args = parser.parse_args()
+
+remoteNo=1
+tomls=[]
+remotes=LircdParser(args.input, args.encoding).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)