2011-12-21

Custom software for interfacing via USB with multimeters UNI-T UT81B

These multimeters are rather pocket oscilloscopes. Quite an interesting package. And the possibility of interfacing via USB with a computer should multiply the possibilities.

Alas, the included software could hardly be crappier. (why do so many manufacturers do something justifiably interesting and then neuter it by blowing up what should be a minor part of the whole?)

At work we have a number of these multimeters, and we were thinking about automating some tests using them. But that would require somehow automating the crappy software and maybe even using various computers (I seem to remember that there was no option to read simultaneously various multimeters from a single computer).

So, I jumped at the possibility of hacking the multimeter protocol and whipping up together our own program. And... it worked! From first idea to working product in 5 days, and having to learn Python and libUSB on the way! (Luckily, I already had had some exposure to the general workings of USB protocols, which helped)

And not only that: our program bests the native one in that it has better precision (the native software averages every 2 samples), faster reaction times, and can read from an arbitrary number of multimeters in a single computer - even if the crappy interface cables all have the same USB PIDs and VIDs and serial numbers. Which they do, being as crappy as they are.

So, what did I do? First, capture and study the USB traffic between the multimeters and native software, using USBSnoop. Then, reverse engineer it: make sense of differences between different multimeter modes, different values shown in the multimeter, and the corresponding USB captures. Little by little, find which byte meant what. After having some basic understanding, prepare my own LibUSB driver and start talking to / reading from the multimeter via Python, and preparing little script which tested for more and more parts of the multimeter's answers (it only accepts one order: "dump whatever you have now", all control about measuring modes is only manual). Find the occasional non-sense outputs (crappy!), find how to recover... and even fix them!

And here are all those Python scripts and the explanations of those bytes.
This was done more than a year ago, only recently I realized that this might be useful to other people; so this a somewhat cleaned summary of the notes and docs I wrote. It should be enough to recreate everything (and I'm sure the mm.py file does work once pyUSB is installed), but if some information is missing let me know and I'll try chasing it.

One final note about the python source: remember, I was learning python at the moment. I don't think it does anything particularly stupid or inelegant (it's quite simple after all), but I wouldn't bet :).

Installation of LibUSB drivers for the multimeter/s


UNI-T multimeters use a USB interface cable that is detected by Windows like a Human Interface Device (HID), so it uses a standard driver included with Windows.
To use the multimeters with our own software, a special driver must be installed. The procedure is the normal procedure for LibUSB-win32:

  • Using the libUSB wizard (LibUSB-win32/bin/x86/inf-wizard.exe), create an .inf file (so Windows will know how to use our driver with the USB interface cable).
  • Then, "update" the HID device originally detected by Windows to use our .inf file and our libUSB driver. This is done in the hardware section of Windows' System control panel: find the HID device, right-click on it, and select "Update driver". Then, the normal Windows device installation wizard will appear. In it, choose: do not connect to Windows Update; install from specific location; don't search, I will select a location; and finally choose the .inf file created by LibUSB's wizard.

Note that the USB cable can originally be recognized by Windows as 2 different, simultaneous HID devices. One of them will accept the update, the other will not. So if one of them fails, try with the second device.

At this point, the multimeter will no longer appear as a HID device, but as a LibUSB one (with the name that was selected on the LibUSB wizard). The multimeter should also be now visible with the  test program of LibUSB ( LibUSB-win32/bin/x86/testlibusb-win.exe )

Note that the "update" has to be repeated each time that the USB cable is connected to a different USB port (but the .inf file can be the same).

Using multiple multimeters


If there are multiple multimeters, they can be distinguished by the VendorID (VID) and ProductID (PID) of the interface cables; and those IDs can be seen in Windows device manager, or when creating the .inf file, or with testlibusb-win once the libUSB driver is managing the device.
But if there are multiple cables with the same VID and PID (this can happen with UNI-T multimeters), an extra parameter is needed to distinguish them: this parameter is "devnum", which is a number assigned by Windows to every device, and which can change every time a device is plugged.
The devnum can be found with testlibusb-win: the first line of every device managed by LibUSB shows a line like
bus-0/\\.\libusb0-0001–0x04fa-0x2490     04FA/2490
In this line, 0001 is devnum, 0x04fa is VID and 0x2490 is PID.

Also, the mm.py python module contains a function called identify_multimeters() which shows detected multimeters and the mode in which they are running. It can be used directly with something like "\Python26\python.exe mm.py" from the DOS prompt (if Python is installed and while inside the mm.py directory, of course).

Protocol explanations

The cable delivers continuously data, even if the multimeter is not connected. The first step is realizing that each "train" of data has a variable number of meaningful bytes, and then some padding to fill up to a constant number of bytes. The lower nibble of byte 0 of each "train" says how many of those bytes are meaningful (not padding). This is taken care of in getAnswer.

So at this point we can see that there are bursts of those meaningful bytes, and pauses (that is, sequences of trains which carry 0 meaningful bytes).

The next problem is that the bursts do carry arbitrary amounts of "real data packets"; a "real data packet" sent by the multimeter can be broken up in some bursts. Worse, there is some buffering in the multimeter or cable, and if a packet starts to be sent but is not read quickly enough, the buffer will be overwritten - so you get some nonsensical burst.
Luckily, the first bytes of a "real data packet" seem to be a constant header. I ended up relying on the first 4 bytes after a pause being 0x5A, 0, 3, 5 to detect a new "real data packet"; and trying to purge any buffered burst (by reading and discarding the results) before starting my requests and reads.

The first 41 bytes of a data packet are non-graphical data: they contain information about the mode the multimeter is currently on, the scale, the reading, etc. The meaning of some bytes depend on the mode (A, mA, V, etc)

mode AC/DC (current measurements)
byte 11
DC=00, AC=01
byte 12
3=200uA, 4=500uA, 5=1mA (no more)
byte 22
20=+, 2D=-

mode mA
byte 12
B=100mA, A=50mA, 9=20mA

mode A
byte12
F=2A, 10=5A
byte 14
6=100ns, 9=1us, C=10us, F=100us, 12=1ms, 15=10ms, 18=100ms, 1B=1s, 1D=5s

byte 9
01=vertical range in AUTO, 00=manual
byte 10
same thing for hztl range

byte 21: 20 bytes of ASCII dump of the measures shown by the MMeter. Padding can be done with spaces or \0 chars.
byte 7: 80=V measure, 81=A measure, 82=Hz measure
byte 18: Trigger 0=Auto, 1=Norm, 2=shot (If the MMeter is waiting for triggering, it won't answer!)
byte 16: Trigger slope 1=falling, 0=rising
byte 8: running=1 / hold=0 (so if MMeter was in Shoot mode, the shoot can be detected because the MMeter goes to Hold mode)
byte 17: Trigger level
byte 15: "horizontal" time base
byte 13: vertical offset

  • "Requests" are remembered! So sending 5 requests will cause 5 answers. So sending multiple reqs is not good (because we aren't sure of when the multimeter is busy), and not necessary (because in normal conditions the multimeter remembers the request; looks like only mode changes (from A to V and such) can cause it to forget to answer).
  • Answers are sent by the Multimeter at the end of a sweep. After an answer, there will not be another answer until at least 2 sweeps later. So for example, if the sweep time is 8 seconds, a request sent during between t=0 and t=8 will be answered in t=8. Any request sent between t=8 and 24 will be answered at t=24.
  • SO: normal answer times can be up to 2*max sweep time=2x40=80 secs! So strict timeout detection should use a longer time than that. However, if the Multimeter did not serve any request in the last sweep, the answer time should be <= sweep time.


Protocol for single readouts / non-graphic modes


packet = 41 bytes
start=5a000003 (maybe in different trains!)
wait time between request and answer seems to be 2 trains. Best option seems to be to detect 1 empty train and 2 first data bytes being 5a00
Multimeter is quick enough to answer a few times per second, but answers are just repeated if requests are closer than about 150ms. So it doesn't make sense to ask more frequently.

byte 12=range. 2=400mV, 5=4V, 8=40V, B=400V, D=750V
BUT, when in Auto, 0=400mV, 3=4V, 6=40V, 9=400V
in A: 2=400uA, 5=4000uA, 8=40mA, B=400mA, E=4A, 10=10A,
BUT, when in Auto, 6=40mA ... auto seems to be always like -2 to the byte value
byte 11=AC/DC. 0=DC, 1=AC; both in V and A modes
byte 9=auto. 0=off, 1=on, both V and A
byte 20=absolute/relative measure. 00=abs, 1=rel, V and A

When a 90 appears in the graph data, it is duplicated! (maybe in the header too?). If they are de-duplicated the packets go back to their expected size.

Graphs are written as the full array of measurements that make up the graph - not as a bitmap. See the function printGraphValues. The values are between about -90 and 90, so they have to be scaled and interpreted using the mode information. Interestingly the graph data is more than what the multimeter itself shows, but also somewhat noisier; looks like the multimeter does some averaging of pairs of samples.

The function identify_multimeters was intended as a quick full identification and read of the connected multimeters. It looks for all the multimeters with a libUSB driver installed, queries them and shows their current mode and reading. So it shows from beginning to end most everything needed to interface with the multimeter.

Also, there are a number of progressively complicated functions. They were used during testing and reverse engineering of the protocol. I'm leaving them here for completeness, they could be interesting to complete the "reversing" of the parts that I didn't need (like frequency readings, for example).

UPDATE: I have put this code in a GitHub repository too.


Python source code (mm.py)

dhG=None
timeout=85

def enum(): #lists the devices found by libUSB-win32
    import usb
    bs=usb.busses()
    print len(bs), " busses found"
   
    for b in bs:
        print "bus: ",b
        ds=b.devices
        print len(ds), " devices found"
        print
        for i in range(0,len(ds)):
            d=ds[i]
            print "DEVICE %d: PID: %04X, VID: %04X, devnum: %X" % (i, d.idVendor,d.idProduct, d.devnum),
            dh=d.open()
            print "manuf:",dh.getString(d.iManufacturer,1000),", SN:",dh.getString(d.iSerialNumber,1000),", Product description:",dh.getString(d.iProduct,1000),", dev version:", d.deviceVersion
            c=d.configurations[0]
            print "config:",c.value,",",dh.getString(c.iConfiguration,1000)
            ifs=c.interfaces
            i=ifs[0][0]
            es=i.endpoints



def init(n=0): #opens the device, returns its deviceHandle
    import usb
    #global dh
    b=usb.busses()
    d=b[0].devices[n]
    print "DEVICE ",n,": PID:", d.idProduct ," VID: ",d.idVendor,", devnum: ",d.devnum
    dh=d.open()
    print "manuf:",dh.getString(d.iManufacturer,1000),", SN:",dh.getString(d.iSerialNumber,1000)
    c=d.configurations[0]
    try:
        print "config:",c.value,",",dh.getString(c.iConfiguration,1000)
    except:
        pass
    ifs=c.interfaces
    i=ifs[0][0]
    es=i.endpoints
    for e in es:
        if e.address>=128:
            ei=e.address
        else:
            eo=e.address
    dh.setConfiguration(c)
    global dhG
    try:
        dh.claimInterface(i)
        dhG=dh
    except usb.USBError:
        print "                    INTERFACE WAS BUSY; RELEASING..."
        dhG.releaseInterface()
        #dh.releaseInterface()
        dh.claimInterface(i)
        dhG=dh
    return {"hnd":dh,"in":ei,"out":eo}


def connect(device_info): # sends command used by MMeter software when pressing the "Connect" button
    device_info["hnd"].controlMsg(0x21,0x9,(0x80,0x25,0,0,3),0x300)

def disconnect(device_info): # sends command used by MMeter software when pressing the "Disconnect" button
    device_info["hnd"].controlMsg(0x22,0x9,(0x80,0x25,0,0,3),0x300)
   
def ask(device_info): # causes the multimeter to dump a packet of data (401 bytes)
    device_info["hnd"].bulkWrite(device_info["out"],(2,0x5a,0,0,0,0,0,0))

   
def connect2(di):
    connect(di)
    dump(di)

def disconnect2(di):
    disconnect(di)
    dump(di)
   
def ask2(di):
    ask(di)
    dump(di)
   
   
def dumpRaw(di, maxt=0, maxtrains=0): #prints all bytes read
    import time
    t0=time.clock()
    bytesRead=0
    emptyTrains=0
    try:
        while True:
            output=di["hnd"].bulkRead(di["in"],8)
            actualBytesInOutput=output[0] & 15
            bytesRead+=actualBytesInOutput
            t=time.clock()-t0
            printout= "%06d" % (t*1000)
            if actualBytesInOutput>0:
                if emptyTrains>0:
                    printout= "(%d ms, %d trains empty...)\n%s"%((t-te)*1000,emptyTrains,printout)
                    emptyTrains=0
                for n in output:
                    printout+= " %02X" % (n)
                printout+="\t%d\n" % bytesRead
            else:
                if emptyTrains==0:
                    te=t
                emptyTrains+=1
                printout+="\r"
            print printout,
            if maxt!=0 and t>maxt:
                break
            if maxtrains!=0 and bytesRead>0 and emptyTrains>maxtrains:
                break
    except KeyboardInterrupt:
        print


def dumpRawSM(di, maxt=0): #prints all bytes read, distinguishing probable stale data and valid data
    import time
    t0=time.clock()
    t=0
    bytesRead=0
    emptyTrains=0
    state=0
    try:
        while (not (maxt!=0 and t>maxt)) and (not(bytesRead>0 and emptyTrains>5)):
            output=di["hnd"].bulkRead(di["in"],8)
            actualBytesInOutput=output[0] & 15
            t=time.clock()-t0
            printout= "%06d" % (t*1000)
            if actualBytesInOutput>0:
                if emptyTrains>3 or bytesRead>0:
                    bytesRead+=actualBytesInOutput
                if emptyTrains>0:
                    printout= "(%d ms, %d trains empty...)\n%s"%((t-te)*1000,emptyTrains,printout)
                    emptyTrains=0

                for n in output:
                    printout+= " %02X" % (n)
                printout+="\t%s\n" % (bytesRead if bytesRead>0 else "STALE")
            else:
                if emptyTrains==0:
                    te=t
                emptyTrains+=1
                printout+="\r"
            print printout,
            # if maxt!=0 and t>maxt:
                # break
            # if state==1 and emptyTrains>5:
                # break
    except KeyboardInterrupt:
        print


       
def dump(di):  #prints data bytes (cleans headers and padding zeros used by serial comm)
    import time
    t0=time.clock()
    bytesRead=0
    try:
        while True:
            output=di["hnd"].bulkRead(di["in"],8)
            actualBytesInOutput=output[0] & 15
            bytesRead+=actualBytesInOutput
            t=time.clock()-t0
            print "%06d" % (t*1000),
            for n in output[1:actualBytesInOutput]:
                print "%02X" % (n),
            print       
    except KeyboardInterrupt:
        pass
   
       
def close(di):
    #print "                CLOSING INTERFACE"
    di["hnd"].releaseInterface()

   
def printGraphValues(di,maxval=100): #prints the values that make up the graph, with value conversion and spacing the divisions
    data=getAnswer2(di,300)
    #print len(data),"bytes:"
    overloads=0;
    vertical_offset=data[12]
   
    for i in range(41,min(len(data),maxval)):
        if (i-41)%40==0:
            print
        o=data[i]
        print o if o<127 else -(255-o),
        if o==90:
            overloads+=1;
    print "length=",len(data)," overloads=",overloads," len-ol/2=",len(data)-overloads/2
    return
       

def printValues(di): #print 50 header bytes
    import time
    t0=time.clock()
    bytesRead=0
    t=0
    emptyTrains=0
    while  not( t>timeout or (emptyTrains>5 and bytesRead>0)):
        #print bytesRead," ",emptyTrains," ",t
        output=di["hnd"].bulkRead(di["in"],8)
        t=time.clock()-t0
        #print "%06d" % (t*1000),
        actualBytesInOutput=output[0] & 15
        if actualBytesInOutput==0:
            emptyTrains+=1
        else:
            emptyTrains=0
        bytesRead+=actualBytesInOutput
        if bytesRead in range(0,50):
            for n in range(1,actualBytesInOutput+1):
                o=output[n]
                print "%02X " % (o),
    print
    print "\nRead %d bytes in %f secs; emptyTrains == %d" % (bytesRead, t, emptyTrains)
    return bytesRead

def getAnswer(di): #return list of read bytes; stop reading when timeout or when 10 empty trains appear after some bytes were already read
    import time
    result=()
    t0=time.clock()
    bytesRead=0
    t=0
    emptyTrains=0
    while  not( t>timeout or (emptyTrains>200 and bytesRead>0)):  #stop conditions: timeout OR (some bytes read AND 200 consecutive empty trains)
    #while  not(emptyTrains>5 and bytesRead>0):
        #print " ",bytesRead," ",emptyTrains," ",t,"\r",
        output=di["hnd"].bulkRead(di["in"],8)
        t=time.clock()-t0
        actualBytesInOutput=output[0] & 15
        emptyTrains= 0 if actualBytesInOutput!=0 else emptyTrains+1
        bytesRead+=actualBytesInOutput
        result+=output[1:actualBytesInOutput+1]
    print bytesRead," ",emptyTrains," ",t
    return result   
       
       
def getAnswer2(di,maxEmptyTrains=150): #return list of read bytes; stop reading when good answer, or timeout or when 150 empty trains appear after some bytes were already read
    import time
    result=()
    t0=time.clock()
    bytesRead=0
    t=0
    emptyTrains=0
    gotGoodAnswer=False
    while  not( t>timeout or (emptyTrains>maxEmptyTrains and bytesRead>0) or gotGoodAnswer):  #stop conditions: timeout OR (some bytes read AND x consecutive empty trains)
    #while  not(emptyTrains>5 and bytesRead>0):
        #print " ",bytesRead," ",emptyTrains," ",t,"\r",
        output=di["hnd"].bulkRead(di["in"],8)
        t=time.clock()-t0
        actualBytesInOutput=output[0] & 15
        emptyTrains= 0 if actualBytesInOutput!=0 else emptyTrains+1
        bytesRead+=actualBytesInOutput
        result+=output[1:actualBytesInOutput+1]
        # if len(result) in (41,361) and result[0:2]==(0x5a,0) :
            # gotGoodAnswer=True
    #print bytesRead," ",emptyTrains," ",t
    return result   
               
       
       
def go(n=0): #ask, clear presumed stale buffer, print first 50 data bytes
    global dhG
    di=init(n)
    dhG=di
    try:
        connect(di)
        ask(di)
        #clearBuffer(di)
        numbytes=printValues(di)
        disconnect(di)
    finally:
        close(di)
    return numbytes>0

def go2(nd=0,nr=1): #ask nr times, print raw response until ^C
    global dhG
    di=init(nd)
    dhG=di
    try:
        connect(di)
        # dumpRaw(di)
        for i in range(0,nr):
            ask(di)
            dumpRaw(di,0,300)
        disconnect(di)
    finally:
        close(di)
       
def go3(n): #like go() but repeatedly
    global dhG
    di=init(n)
    dhG=di
    try:
        connect(di)
        while True:
            clearBuffer(di)
            ask(di)
            printGraphValues(di,1000)
            print
        disconnect(di)
    finally:
        close(di)
    return numbytes>0
       
       
def clearBuffer(di):
    #we want 10 consecutive reads bearing 0 data bytes to be sure that there is no stale data waiting.
    emptyTrains=0
    while emptyTrains<10:
        output=di["hnd"].bulkRead(di["in"],8)
        actualBytesInOutput=output[0] & 15
        if actualBytesInOutput==0:
            emptyTrains+=1
        else:
            emptyTrains=0
            print "B"
       
def go4(n): # to see the first 20 header bytes, their changes and the ASCII reading.
    import time
    global dhG
    di=init(n)
    dhG=di
    oldData=data=()
    tOldData=0
    spinner="/\\"
    s=0
    t0=time.clock()
    try:
        connect(di)
        while True:
            while True:
                ask(di)
                #clearBuffer(dh)
                print "\x08\x08%s" % (spinner[s]),
                s+=1
                if s==len(spinner):
                    s=0
                t=time.clock()-t0
                data=getAnswer(di)
                if data[0:2]==(0x5A, 0):
                    break
                if data==():
                    print "timeout"
                else:
                    print "bad answer,",len(data),"bytes:"
                    break
            printout=""
            somethingChanged=False
            for i in range(0,21):
                try:
                    if data[i]==oldData[i]:
                        printout+= ".. "
                    else:
                        printout+= "%02X " % (data[i])
                        #somethingChanged=True
                except IndexError:
                    printout+= "   " if len(data)<=i else "%02X " % (data[i])
                    #somethingChanged=True
            for i in range(21,41):    #ASCII decoding
                try:
                    c=chr(data[i]) if data[i]!=0 else '_'
                    #if data[i]==oldData[i]:
                    #    printout+= " "
                    #else:
                    printout+=c
                except IndexError:  #part of the expected ASCII is missing! mark with *
                    printout+= '*'                   
            #print "=",
            #if somethingChanged:
            if oldData!=data:
                print "\r", printout, t-tOldData
                if not len(data) in (361,41):
                    print "LENGTH WAS",len(data)
            else:
                print ".\x08\x08",
            oldData=data
            tOldData=t
        disconnect(di)
    finally:
        close(di)   

def go5(nd=0,nr=1): #repeat nr times (ask + dumpraw), print raw response until ^C
    global dhG
    import time
    di=init(nd)
    dhG=di
    try:
        connect(di)
        for i in range(0,nr):
            ask(di)
            dumpRaw(di)
        disconnect(di)
    finally:
        close(di)
       
def go6(nd=0,nr=1, maxt=0): #repeat nr times (ask + dumpraw + wait n secs), print raw response until ^C
    global dhG
    import time
    di=init(nd)
    dhG=di
    try:
        connect(di)
        print "Initial read to clear pending data..."
        dumpRawSM(di, maxt)
        for i in range(0,nr):
            print "Asking..."
            ask(di)
            dumpRawSM(di, maxt)
            if maxt!=0:
                print "Waiting",maxt+1
                time.sleep(maxt+1)
        disconnect(di)
    finally:
        close(di)
       
def go7(nd=0,nr=1, maxt=0): #just dump whatever is read, without requesting anything. Useful to make sure there are no requests queued.
    global dhG
    import time
    di=init(nd)
    dhG=di
    try:
        connect(di)
        dumpRawSM(di, maxt)
        disconnect(di)
    finally:
        close(di)
       
def identify_multimeters():
    import usb
    timescale = {
        6 : "100ns",
        7 : "200ns",
        8 : "500ns",
        9 : "1us",
        0xA : "2us",
        0xB : "5us",
        0xC : "10us",
        0xD : "20us",
        0xE : "50us",
        0xF : "100us",
        0x10: "200us",
        0x11: "500us",
        0x12: "1ms",
        0x13: "2ms",
        0x14: "5ms",
        0x15: "10ms",
        0x16: "20ms",
        0x17: "50ms",
        0x18: "100ms",
        0x19: "200ms",
        0x1A: "500ms",
        0x1B: "1s",
        0x1C: "2s",
        0x1D: "5s"
        }
    modes_voltage = {
        0 : "20mV",
        1 : "50mV",
        2 : "100mV",
        3 : "200mV",
        4 : "500mV",
        5 : "1V",
        6 : "2V",
        7 : "5V",
        8 : "10V",
        9 : "20V",
        0xA: "50V",
        0xB: "100V",
        0xC: "200V",
        0xD: "V"
        }
    modes_amperage = {
        3 : "200uA",
        4 : "500uA",
        5 : "1mA",
        9 : "20mA",
        0xA: "50mA",
        0xB: "100mA",
        0xF: "2A",
        0x10: "5A"
        }
    bs=usb.busses()
    print len(bs), " busses found"
   
    for b in bs:
        #print "bus: ",b
        ds=b.devices
        print len(ds), " devices found"       
        for i in range(0,len(ds)):
            print
            state=0
            d=ds[i]
            print "DEVICE IDENTIFICATION: PID: %04X, VID: %04X, devnum: %X" % (d.idVendor, d.idProduct, d.devnum)
            dh=d.open()
            state=1
            print "DEVICE DETAILS: manuf:",dh.getString(d.iManufacturer,1000),", SN:",dh.getString(d.iSerialNumber,1000),", Product description:",dh.getString(d.iProduct,1000),", dev version:", d.deviceVersion
            c=d.configurations[0]
            ifs=c.interfaces
            i=ifs[0][0]
            es=i.endpoints
            for e in es:
                if e.address>=128:
                    ei=e.address
                else:
                    eo=e.address
            dh.setConfiguration(c)
            di={"hnd":dh,"in":ei,"out":eo}
            global dhG
            try:
                dh.claimInterface(i)
                state=2
                connect(di)
                state=3
                tries=0
                while tries<4:
                    print "Asking for multimeter data:",
                    ask(di)
                    state=4
                    #clearBuffer(di)
                    data=getAnswer(di)
                    if data[0:4]==(0x5A, 0, 3, 5):
                        break
                    tries+=1
                    if data==():
                        print "timeout..."
                    else:
                        print "unexpected answer..."
                if tries==4:
                    #we arrived here because there was no good answer
                    print "This device does not answer as expected from a Multimeter. If it is, please turn it off and disconnect the cable; then, reconnect, and turn on the multimeter."
                    continue
                #if we are here, we had no USB error, no timeout and a good header, so we assume a good answer
                print "Got data!"
                print "STATUS:"
                print "Vertical scale:",
                mode=data[6]
                if mode==0x80:
                    print modes_voltage[data[11]],
                elif mode==0x81:
                    print modes_amperage[data[11]],
                else:
                    print "Mode is not Voltage nor Amperage measurement",
                   
                if data[9]==1:
                    print "[AUTO]"
               

                print "Time scale:",timescale[data[13]],
                if data[8]==1:
                    print "[AUTO]"
               
                print "Graph:","running" if data[7]==1 else "held"
                print "Readout:",
                printout=""
                for i in range(20,40):     #ASCII decoding
                    try:
                        c=chr(data[i]) if data[i]!=0 else ' '
                        printout+=c
                    except IndexError:  #part of the expected ASCII is missing!
                        printout+= ' '               
                print printout
               
                disconnect(di)
                state=5
            finally:
                if state==1:
                    print "USB communication error"
                elif state in range(2,5):
                    print "Something happened! state=",state
                    close(di)   
                   
                   
                   
if __name__ == "__main__":
    identify_multimeters()

4 comments

  1. Of course, the same general procedures can be reimplemented in other languages; these scripts were the testing phase, and then I had to reimplement the functionality in Borland C++ v5 (last updated on the 90's!). Nasty. (I had no choice...)

    Luckily the Python experiments had left everything so clear, and so quickly testable, that the reimplementation was a matter of few hours.

    ReplyDelete
  2. I am so frustrated by the UT81B software, that I was going to write my own.. Few years ago when I bought this meter I asked the UNI-TREND for a spec of the protocol, you may find it at http://www.lowlevel.cz/log/cats/english/UNI-T%20UT81B.html

    Did you advanced any further with your software, is there anything wrong with the Python code? I would like to stick with it if possible... Thanks anyway for posting, I am going to try it.

    ReplyDelete
  3. The Python code was just for prototyping, but it was perfectly functional; if I hadn’t needed the C implementation I would have stopped there. I don’t think that there was any further refinement in the C implementation step.

    I have to admit that I didn’t even think to ask for an official protocol specification, I just jumped into reverse engineering. In my defense, the existing software and documentation were so bad that I didn’t even hope to get an answer. And now, checking the protocol spec you got, I see I was not too wrong anyway ;P.

    For example, in my description of the protocol I talk about the value 90 being duplicated whenever it appeared in a packet. I tried just now to find the explanation for that in the spec, and I only found a cryptic passage which talks about “X5A” which probably means 0x5A, which equals 90. But I would never guessthe meaning from the text itself…

    Also, that spec doesn’t say anything about timing and packeting and such. Mhm.

    Anyway, good luck. All of this was long ago, but if there is any doubt in which I could help, let me know.

    ReplyDelete
  4. For me the python is pretty useable, here is the result ATM:
    dmm_ut81b.py

    it is not 100% and definitely not tested too much but does what I need - signle measurement with wafeform in correct scales with a zoomable picutre and continous captures of either a single measurement or waveform logged into a file.

    Thanks a lot for your code, that made me to get into it!

    ReplyDelete