Source code for fints.hhd.flicker

# Inspired by:
# https://github.com/willuhn/hbci4java/blob/master/src/org/kapott/hbci/manager/FlickerCode.java
# https://6xq.net/flickercodes/
# https://wiki.ccc-ffm.de/projekte:tangenerator:start#flickercode_uebertragung
import math
import re
import time

HHD_VERSION_13 = 13
HHD_VERSION_14 = 14
LC_LENGTH_HHD14 = 3
LC_LENGTH_HHD13 = 2
LDE_LENGTH_DEFAULT = 2
LDE_LENGTH_SPARDA = 3
BIT_ENCODING = 6  # Position of encoding bit
BIT_CONTROLBYTE = 7  # Position of bit that tells if there are a control byte
ENCODING_ASC = 1
ENCODING_BCD = 2


def parse(code):
    code = clean(code)
    try:
        return FlickerCode(code, HHD_VERSION_14)
    except:
        try:
            return FlickerCode(code, HHD_VERSION_14, LDE_LENGTH_SPARDA)
        except:
            return FlickerCode(code, HHD_VERSION_13)


def clean(code):
    if code.startswith('@'):
        code = code[res.challenge_hhd_uc.index('@', 2) + 1:]
    code = code.replace(" ", "").strip()
    if "CHLGUC" in code and "CHLGTEXT" in code:
        # Sometimes, HHD 1.3 codes are not transferred in the challenge field but in the free text,
        # contained in CHLGUCXXXX<code>CHLGTEXT
        code = "0" + code[code.index("CHLGUC") + 11:code.index("CHLGTEXT")]
    return code


def bit_sum(num, bits):
    s = 0
    for i in range(bits):
        s += num & (1 << i)
    return s


def digitsum(n):
    q = 0
    while n != 0:
        q += n % 10
        n = math.floor(n / 10)
    return q


def h(num, l):
    return hex(num).upper()[2:].zfill(l)


def asciicode(s):
    return ''.join(h(ord(c), 2) for c in s)


def swap_bytes(s):
    b = ""
    for i in range(0, len(s), 2):
        b += s[i + 1]
        b += s[i]
    return b


class FlickerCode:
    def __init__(self, code, version, lde_len=LDE_LENGTH_DEFAULT):
        self.version = version
        self.lc = None
        self.startcode = Startcode()
        self.de1 = DE(lde_len)
        self.de2 = DE(lde_len)
        self.de3 = DE(lde_len)
        self.rest = None
        self.parse(code)

    def parse(self, code):
        length = LC_LENGTH_HHD14 if self.version == HHD_VERSION_14 else LC_LENGTH_HHD13
        self.lc = int(code[0:length])
        if len(code) < length+self.lc:
            raise ValueError("lc too large: {} + {} > {}".format(self.lc, length, len(code)))
        code = code[length:]
        code = self.startcode.parse(code)
        self.version = self.startcode.version
        code = self.de1.parse(code, self.version)
        code = self.de2.parse(code, self.version)
        code = self.de3.parse(code, self.version)
        self.rest = code or None

    def render(self):
        s = self.create_payload()
        luhn = self.create_luhn_checksum()
        xor = self.create_xor_checksum(s)
        return s + luhn + xor

    def create_payload(self):
        s = str(self.startcode.render_length())
        for b in self.startcode.control_bytes:
            s += h(b, 2)
        s += self.startcode.render_data()
        for de in (self.de1, self.de2, self.de3):
            s += de.render_length()
            s += de.render_data()

        l = (len(s) + 2) // 2  # data + checksum / chars per byte
        lc = h(l, 2)
        return lc + s

    def create_xor_checksum(self, payload):
        xorsum = 0
        for c in payload:
            xorsum ^= int(c, 16)
        return h(xorsum, 1)

    def create_luhn_checksum(self):
        s = ""
        for b in self.startcode.control_bytes:
            s += h(b, 2)
        s += self.startcode.render_data()
        if self.de1.data is not None:
            s += self.de1.render_data()
        if self.de2.data is not None:
            s += self.de2.render_data()
        if self.de3.data is not None:
            s += self.de3.render_data()

        luhnsum = 0
        for i in range(0, len(s), 2):
            luhnsum += 1 * int(s[i], 16) + digitsum(2 * int(s[i + 1], 16))

        m = luhnsum % 10
        if m == 0:
            return "0"
        r = 10 - m
        ss = luhnsum + r
        luhn = ss - luhnsum
        return h(luhn, 1)


class DE:
    def __init__(self, lde_len):
        self.length = 0
        self.lde = 0
        self.lde_length = lde_len
        self.encoding = None
        self.data = None

    def parse(self, data, version):
        self.version = version
        if not data:
            return data
        self.lde = int(data[0:self.lde_length])
        data = data[self.lde_length:]

        self.length = bit_sum(self.lde, 5)
        self.data = data[0:self.length]
        return data[self.length:]

    def set_encoding(self):
        if self.data is None:
            self.encoding = ENCODING_BCD
        elif self.encoding is not None:
            pass
        elif re.match("^[0-9]{1,}$", self.data):
            # BCD only if the value is fully numeric, no IBAN etc.
            self.encoding = ENCODING_BCD
        else:
            self.encoding = ENCODING_ASC

    def render_length(self):
        self.set_encoding()
        if self.data is None:
            return ""
        l = len(self.render_data()) // 2
        if self.encoding == ENCODING_BCD:
            return h(l, 2)

        if self.version == HHD_VERSION_14:
            l = l + (1 << BIT_ENCODING)
            return h(l, 2)

        return "1" + h(l, 1)

    def render_data(self):
        self.set_encoding()
        if self.data is None:
            return ""

        if self.encoding == ENCODING_ASC:
            return asciicode(self.data)

        if len(self.data) % 2 == 1:
            return self.data + "F"

        return self.data


class Startcode(DE):
    def __init__(self):
        super().__init__(LDE_LENGTH_DEFAULT)
        self.control_bytes = []

    def parse(self, data):
        self.lde = int(data[:2], 16)
        data = data[2:]

        self.length = bit_sum(self.lde, 5)

        self.version = HHD_VERSION_13
        if self.lde & (1 << BIT_CONTROLBYTE) != 0:
            self.version = HHD_VERSION_14
            for i in range(10):
                cbyte = int(data[:2], 16)
                self.control_bytes.append(cbyte)
                data = data[2:]
                if cbyte & (1 << BIT_CONTROLBYTE) == 0:
                    break

        self.data = data[:self.length]
        return data[self.length:]

    def render_length(self):
        s = super().render_length()
        if self.version == HHD_VERSION_13 or not self.control_bytes:
            return s
        l = int(s, 16) + (1 << BIT_CONTROLBYTE)
        return h(l, 2)

def code_to_bitstream(code):
    """Convert a flicker code into a bitstream in strings."""
    # Inspired by Andreas Schiermeier
    # https://git.ccc-ffm.de/?p=smartkram.git;a=blob_plain;f=chiptan/flicker/flicker.sh;h
    # =7066293b4e790c2c4c1f6cbdab703ed9976ffe1f;hb=refs/heads/master
    code = parse(code).render()
    data = swap_bytes(code)
    stream = ['10000', '00000', '11111', '01111', '11111', '01111', '11111']
    for c in data:
        v = int(c, 16)
        stream.append('1' + str(v & 1) + str((v & 2) >> 1) + str((v & 4) >> 2) + str((v & 8) >> 3))
        stream.append('0' + str(v & 1) + str((v & 2) >> 1) + str((v & 4) >> 2) + str((v & 8) >> 3))
    return stream

[docs]def terminal_flicker_unix(code, field_width=3, space_width=3, height=1, clear=False, wait=0.05): """ Re-encodes a flicker code and prints it on a unix terminal. :param code: Challenge value :param field_width: Width of fields in characters (default: 3). :param space_width: Width of spaces in characters (default: 3). :param height: Height of fields in characters (default: 1). :param clear: Clear terminal after every line (default: ``False``). :param wait: Waiting interval between lines (default: 0.05). """ stream = code_to_bitstream(code) high = '\033[48;05;15m' low = '\033[48;05;0m' std = '\033[0m' while True: for frame in stream: if clear: print('\033c', end='') for i in range(height): for c in frame: print(low + ' ' * space_width, end='') if c == '1': print(high + ' ' * field_width, end='') else: print(low+ ' ' * field_width, end='') print(low + ' ' * space_width + std) time.sleep(wait)