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:
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
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
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)
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/2490In 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()
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...)
ReplyDeleteLuckily the Python experiments had left everything so clear, and so quickly testable, that the reimplementation was a matter of few hours.
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
ReplyDeleteDid 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.
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.
ReplyDeleteI 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.
For me the python is pretty useable, here is the result ATM:
ReplyDeletedmm_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!
I got a bug report in GitHub that made me realize how out of date my code is - so I added a deprecation notice and linked to the modernized version by the Anonymous commenter above.
ReplyDeleteIf anyone is still interested in this, looks like that is the way to go.