#!/usr/bin/python3 # vim: set fileencoding=utf-8 : # # Decoder for multimeters based on the Fortune Semiconductor FS9721 chip # and the pin compatible Semico CS7721CN chip. These chips can send the # contents of their display to a PC via a serial connection which is # transmitted via wireless, USB or RS232 depending on the meter. This # program was tested with a Digitech QM1571 multimeter, but other # multimeters based on those chip sets should work. This is a selection, # listed here mainly to perk the interest of the search engines: # - Altronics Q1079, # - Digitech QM1571, # - DT4000ZC, # - Harbor Freight 98674, # - Mastech MS8229, # - PCE-DM32, # - Tecpel DMM-8061, # - Tecpel DMM-8061, # - Tenma 72-7745, # - TP4000ZC, # - UT60A, # - UT60E, # - Vichy VC97, # - Voltcraft MT-52, # - Voltcraft VC-820, # - Voltcraft VC-840. # # The output of the multimeter ends up at a serial port set at 2400 baud, # 8 data bits, no parity. The output looks to be the bit map fed to the # LCD (ie, if a bit 1 one an LCD element is turned on) wrapped up a 19 # byte packet. A packet looks like the following series of bytes: # # 16-1d-f1-XX-YY-1Z-2Z-3Z-4Z-5Z-6Z-7Z-8Z-9Z-aZ-bZ-cZ-dZ-eZ # # where: # # 16 - Always 0x16 on my device. # 1d - Always 0x1d on my device. # f1 - Always 0xf1 on my device. # XX - Alternates between 0x55 and 0xaa on my device. # YY - Sum of the following bytes modulo 256 on my device. # 1Z...eZ - The high order nibble (1..e) is always fixed. The low order # nibble (Z) is the bit map fed to the LCD display, telling # you what segments are lit. # # The low order 4 bits (nibble) in each byte correspond to segments on the # LCD. If a bit is on (ie, 1), a segment is lit. The segment corresponding # to each bit in (Z) of the data bytes is: # # Offset in packet Bit7..Bit4 Bit3 Bit2 Bit1 Bit0 # ---------------- ---------- ---- ---- ---- ---- # 5 0001 AC DC AUTO USB-Symbol # 6 0010 - D1:5 D0:6 D1:1 # 7 0011 D1:4 D1:3 D1:7 D1:2 # 8 0100 .1 D2:5 D2:6 D2:1 # 9 0101 D2:4 D2:3 D2:7 D2:2 # 10 0110 .2 D3:5 D3:6 D3:1 # 11 0111 D3:4 D3:3 D3:7 D3:2 # 12 1000 .3 D4:5 D4:6 D4:1 # 13 1001 D4:4 D4:3 D4:7 D4:2 # 14 1010 u n k Diode # 15 1011 m % M beep # 16 1100 F ohm REL HOLD # 17 1101 A V Hz low-bat # 18 1110 degC degF # # To aid in guessing what the hell the above means here is a helpful # picture. Sadly it has to be ASCII art, but if you squint just right # it looks exactly like the display on the multimeter when you first # turn it on and every element is switched on. # # /========================================================================\ # | AUTO REL HOLD Diode beep kM ohm Hz | # | | # | USB |--D1:1--| |--D2:1--| |--D3:1--| |--D4:1--| % | # | | | | | | | | | | # | DC D1:6 D1:2 D2:6 D2:2 D3:6 D3:2 D4:6 D4:2 degC degF | # | | | | | | | | | | # | AC |--D1:7--| |--D2:7--| |--D3:7--| |--D4:7--| | # | | | | | | | | | | # | D1:5 D1:3 D2:5 D2:3 D3:5 D3:3 D4:5 D4:3 n u F | # | | | | | | | | | | # | |--D1:4--| |--D2:4--| |--D3:4--| |--D4:4--| | # | .1 .2 .3 mVA | # \========================================================================/ # # # Home page: https://qm1571-multimeter.sourceforge.net/ # # Author: Russell Stuart, russell-debian@stuart.id.au # # License # ------- # # Copyright (c) 2017,2021 Russell Stuart. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or (at # your option) any later version. # # The copyright holders grant you an additional permission under Section 7 # of the GNU Affero General Public License, version 3, exempting you from # the requirement in Section 6 of the GNU General Public License, version 3, # to accompany Corresponding Source with Installation Information for the # Program or any work based on the Program. You are still required to # comply with all other Section 6 requirements to provide Corresponding # Source. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # import getopt import io import os import serial import sys import time DEFAULT_TTY = '/dev/ttyUSB0' # # Read a packet send by the multimeter from the tty port. # def get_packet(port, synced): # # We are looking for something of the form: # # 16 1d f1 aa fc 17 2f 3d 4f 5d 67 7d 88 95 a0 b8 c0 d4 e8 # input = [] o = -1 while True: o += 1 # # We need an entire packet worth of bytes in the buffer. # while len(input) - o < 19: try: b = port.read(1) except KeyboardInterrupt: sys.stderr.write('\n') sys.exit(0) input.append(ord(b) if isinstance(b, str) else b[0]) #sys.stdout.write("%02x." % b) # # If we have start of packet then what came before must be crap. # if o > 20 or o != 0 and input[o:o + 3] == [0x16, 0x1d, 0xf1]: if not synced: synced = True else: bad = '.'.join("%02x" % (i,) for i in input[:o]) sys.stdout.write('? %s\n' % (bad,)) sys.stdout.flush() del input[:o] o = 0 # # Verify the packet looks legit. # if input[o:o + 3] != [0x16, 0x1d, 0xf1]: continue if input[o + 3] not in (0x55, 0xaa): continue if any(input[i] >> 4 != i - 4 for i in range(5, 19)): continue if sum(i for i in input[5:]) % 256 != input[4]: continue break return tuple(input[i] & 0xF for i in range(5, 19)) # # Segments lit for each digit. /--1--\ # 6 2 # +--7--+ # 5 3 # \--4--/ # DIGITS = { # Segments lit, Resulting character '': '', '456': 'L', # They use L, the rest are a bit of whimsy '123567': 'A', '23567': 'H', '12567': 'P', '14567': 'E', '23456': 'U', '56': 'I', '14567': 'F', '234': 'J', '1456': 'C', '123456': '0', '23': '1', '12457': '2', '12347': '3', '2367': '4', '13467': '5', '134567': '6', '123': '7', '1234567': '8', '123467': '9', } # # Translate that into a bit pattern. # DIGITS = { ('5' in k and 0x40 or 0) | ('6' in k and 0x20 or 0) | ('1' in k and 0x10 or 0) | ('4' in k and 0x08 or 0) | ('3' in k and 0x04 or 0) | ('7' in k and 0x02 or 0) | ('2' in k and 0x01 or 0): v for k, v in DIGITS.items() } # # Byte & bit for each symbol. The high order nibble is the offset into # the data, the low order nibble is the bit mask for the bit in the nibble # for that LCD symbol. # SYMBOLS = { 'AC': 0x08, 'DC': 0x04, 'AUTO': 0x02, 'USB': 0x01, '-': 0x18, '.:1': 0x38, '.:2': 0x58, '.:3': 0x78, 'u': 0x98, 'n': 0x94, 'k': 0x92, 'Diode': 0x91, 'm': 0xa8, 'Hz%': 0xa4, 'M': 0xa2, 'beep': 0xa1, 'F': 0xb8, 'ohm': 0xb4, 'REL': 0xb2, 'HOLD': 0xb1, 'A': 0xc8, 'V': 0xc4, 'Hz': 0xc2, 'battery-low!': 0xc1, 'degC': 0xd2, 'degF': 0xd1, } # # Decode the data part of the packet into human readable text. # def decode_packet(packet): def digit(offset): d = packet[offset] << 4 & 0x70 | packet[offset + 1] return DIGITS.get(d, '?') def symbol(sym, fmt='%s', default=''): s = SYMBOLS[sym] if not packet[s >> 4] & (s & 0x0F): return default return fmt % sym.split(':')[0] return ( symbol('-', '%s', '+') + digit(1) + symbol('.:1') + digit(3) + symbol('.:2') + digit(5) + symbol('.:3') + digit(7) + ' ' + symbol('u') + symbol('n') + symbol('m') + symbol('k') + symbol('M') + symbol('V') + symbol('A') + symbol('F') + symbol('ohm', ' %s') + symbol('Hz') + symbol('Hz%') + symbol('Diode', ' %s') + symbol('degC') + symbol('degF') + symbol('DC', ' %s') + symbol('AC', ' %s') + ', ' + symbol('AUTO', '%s ') + symbol('REL', '%s ') + symbol('HOLD', '%s ') + symbol('battery-low!')) # # Entry point. # def main(argv=sys.argv): if len(argv) > 2 or len(argv) == 2 and argv[1].startswith('-'): sys.stderr.write("usage: %s [tty]\n" % (os.path.basename(argv[0]),)) sys.exit(1) tty_device = len(argv) == 1 and DEFAULT_TTY or argv[1] try: port = serial.Serial( port=tty_device, baudrate=2400, bytesize=serial.EIGHTBITS, stopbits=serial.STOPBITS_ONE, parity=serial.PARITY_NONE, timeout=None) if not port.isOpen(): port.open() except IOError as err: sys.stderr.write('%s: %s\n' % (tty_device, str(err))) sys.exit(1) port.flushInput() packet = get_packet(port, False) while True: sys.stdout.write(decode_packet(packet) + "\n") sys.stdout.flush() packet = get_packet(port, True) if __name__ == "__main__": main(sys.argv)