├── AC_COMBOX.py ├── AC_USB_PowerMeter.py ├── LICENSE ├── PZEM004T-option2.pdf ├── PZEM004T-orig.pdf └── README.md /AC_COMBOX.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | #MIT License 3 | # 4 | #Copyright (c) 2021 TheHWcave 5 | # 6 | #Permission is hereby granted, free of charge, to any person obtaining a copy 7 | #of this software and associated documentation files (the "Software"), to deal 8 | #in the Software without restriction, including without limitation the rights 9 | #to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | #copies of the Software, and to permit persons to whom the Software is 11 | #furnished to do so, subject to the following conditions: 12 | # 13 | #The above copyright notice and this permission notice shall be included in all 14 | #copies or substantial portions of the Software. 15 | # 16 | #THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | #IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | #FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | #AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | #LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | #OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | #SOFTWARE. 23 | # 24 | 25 | import serial 26 | import struct 27 | import argparse 28 | from collections import namedtuple 29 | from time import sleep,time,localtime,strftime,perf_counter 30 | 31 | parser = argparse.ArgumentParser() 32 | DEFPORT = '/dev/accom_0' 33 | 34 | 35 | parser.add_argument('--port','-p',help='port (default ='+DEFPORT, 36 | dest='port_dev',action='store',type=str,default=DEFPORT) 37 | parser.add_argument('--out','-o',help='output filename (default=ACCOM_.csv)', 38 | dest='out_name',action='store',type=str,default='!') 39 | parser.add_argument('--time','-t',help='interval time in seconds between measurements (def=1.0)', 40 | dest='int_time',action='store',type=float,default=1.0) 41 | 42 | 43 | parser.add_argument('--reset','-r',help='reset energy ', 44 | dest='reset',action='store_true') 45 | parser.add_argument('--alarm','-a',help='power alarm threshold [W] ', 46 | dest='alarm',action='store',type=int,default=23000) 47 | 48 | parser.add_argument('--debug','-d',help='debug level 0.. (def=1)', 49 | dest='debug',action='store',type=int,default=0) 50 | 51 | 52 | class AC_COMBOX: 53 | 54 | __ACM = None # serial connection to the AC com box 55 | 56 | __SLAVEADD = 1 # address of the AC com box 57 | 58 | __FC_R_HOLD = 3 # function code: Read Hold Regs 59 | __FC_R_INP = 4 # function code: Read Input Regs 60 | __FC_W_SING = 6 # function code: Write Single Reg 61 | __FC_U_CAL = 0x41 # function code (user defined): Calibration (use address 0xF8) 62 | __FC_U_RESET= 0x42 # function code (user defined): Reset Energy 63 | 64 | 65 | __REG_U = 0x00 # 16bit volts in 0.1V resolution 66 | __REG_IL = 0x01 # current lower 16bit, resolution 1 mA 67 | __REG_IH = 0x02 # current higher 16bit, resolution 1 mA 68 | __REG_PL = 0x03 # power lower 16bit, resolution 1 mW 69 | __REG_PH = 0x04 # power higher 16bit, resolution 1 mW 70 | __REG_EL = 0x05 # energy lower 16bit, resolution 1 Wh 71 | __REG_EH = 0x06 # energy higher 16bit, resolution 1 Wh 72 | __REG_F = 0x07 # 16bit frequency, resolution 0.1Hz 73 | __REG_PF = 0x08 # 16bit power factor, resolution 0.01 74 | __REG_ALM = 0x09 # 16bit alarm status, FFFF = alarm, 0 = no alarm 75 | 76 | 77 | __REG_TH = 0x01 # alarm threshold 78 | __REG_ADDR = 0x02 # address 79 | 80 | 81 | # 82 | # The class keeps copies of the actual values in the AC module here 83 | # 84 | __volt = 0.0 # in V 85 | __current = 0.0 # in A 86 | __power = 0.0 # in W 87 | __energy = 0.0 # in Wh 88 | __freq = 0.0 # in Hz 89 | __pf = 0.0 90 | __alarm = 0 91 | __thresh = 0.0 # in W 92 | __addr = 0 93 | 94 | 95 | PollData = namedtuple('PollData',['Volt','Current','Power', 96 | 'Energy','Freq','Pf','Alarm']) 97 | 98 | 99 | 100 | def __dump(self,prompt,buf): 101 | """ 102 | prints a hex dump of the buffer on the terminal 103 | """ 104 | print(prompt,end='') 105 | for b in buf: 106 | print('{:02x} '.format(b),end='') 107 | print() 108 | 109 | def __CRC16(self,buf): 110 | """ 111 | calculates and returns the CRC16 checksum for all message bytes 112 | excluding the two checksum bytes 113 | """ 114 | crc = 0xffff 115 | for b in buf[:-2]: # exclude the checksum space 116 | crc = crc ^ b 117 | for n in range(0,8): 118 | if (crc & 0x0001) != 0: 119 | crc = crc >> 1 120 | crc = crc ^ 0xa001 121 | else: 122 | crc = crc >> 1 123 | return crc.to_bytes(2,'little') 124 | 125 | def __cmd_read_regs(self,slave,fc,regstart,regnum): 126 | """ 127 | implements function code 0x03 or 0x04: 128 | slave : slave address 129 | regstart: address of first register 130 | regnum : number of registers to read 131 | 132 | The expected response for this message varies with regnum. 133 | For a regnum value of 5 we expect 15 bytes back 134 | """ 135 | res = None 136 | if (fc == self.__FC_R_HOLD) or (fc == self.__FC_R_INP): 137 | msg = bytearray(8) 138 | msg[0] = slave 139 | msg[1] = fc 140 | msg[2:4] = regstart.to_bytes(2,byteorder='big') 141 | msg[4:6] = regnum.to_bytes(2,byteorder='big') 142 | msg[6:8] = self.__CRC16(msg) 143 | self.__ACM.write(msg) 144 | res = self.__read_response(5+2*regnum) 145 | else: 146 | raise ValueError 147 | return res 148 | 149 | def __cmd_write_reg(self,slave,reg,data): 150 | """ 151 | implements function code 0x06: write single register 152 | slave : slave address 153 | reg : address of register 154 | data : data to write 155 | 156 | The expected response for this message is always 8 bytes long 157 | """ 158 | msg = bytearray(8) 159 | msg[0] = slave 160 | msg[1] = self.__FC_W_SING 161 | msg[2:4] = reg.to_bytes(2,byteorder='big') 162 | msg[4:6] = data.to_bytes(2,byteorder='big') 163 | msg[6:8] = self.__CRC16(msg) 164 | self.__ACM.write(msg) 165 | res = self.__read_response(8) 166 | return res 167 | 168 | def __cmd_userfunc(self,slave,fc): 169 | """ 170 | implements a user defined function code 171 | slave : slave address 172 | fc : function code 173 | 174 | 175 | The expected response for this message is always 4 bytes long 176 | """ 177 | msg = bytearray(4) 178 | if fc == self.__FC_U_CAL: 179 | msg[0] = 0xf8 180 | else: 181 | msg[0] = slave 182 | msg[1] = fc 183 | msg[2:4] = self.__CRC16(msg) 184 | 185 | self.__ACM.write(msg) 186 | res = self.__read_response(4) 187 | return res 188 | 189 | def __read_response(self,expected_len): 190 | """ 191 | reads and processes the responses received from the module 192 | It does noy rely on "silent" periods to detect message ends 193 | and instead needs the expected message length. 194 | It verifies that the checksum is correct, but the 195 | further interpretation is done "cheaply" and 196 | really only targets the messages we are expecting to see, 197 | namely: 198 | - response to read_regs for 10 registers starting at REG_U 199 | - 200 | 201 | """ 202 | buf = bytearray(128) 203 | buflen = 0 204 | raw = bytearray 205 | res = False 206 | tries = 50 207 | while (tries > 0): 208 | raw = self.__ACM.read(32) 209 | if len(raw) > 0: 210 | # got something .. append in to the buffer 211 | buf[buflen:buflen+len(raw)] = raw 212 | buflen = buflen + len(raw) 213 | if buflen >= expected_len: 214 | break 215 | else: 216 | tries = tries - 1 217 | if tries == 0: 218 | print('timeout') 219 | else: 220 | #self.__dump('msg:',buf[:buflen]) 221 | data = buf[:buflen] 222 | if buflen > 3: 223 | if data[-2:] == self.__CRC16(data): 224 | if data[1:3] == b'\x04\x14': 225 | # Expected response for read_regs of 10 registers starting with REG_U 226 | msg = struct.unpack('>3B11H',data) 227 | self.__volt = float(msg[3+self.__REG_U])*0.1 228 | self.__current = float((0x10000*msg[3+self.__REG_IH]+msg[3+self.__REG_IL]))*0.001 229 | self.__power = float((0x10000*msg[3+self.__REG_PH]+msg[3+self.__REG_PL]))*0.1 230 | self.__energy = float((0x10000*msg[3+self.__REG_EH]+msg[3+self.__REG_EL])) 231 | self.__freq = float(msg[3+self.__REG_F])*0.1 232 | self.__pf = float(msg[3+self.__REG_PF])*0.01 233 | self.__alarm = 1 if msg[3+self.__REG_ALM] == 0xffff else 0 234 | res = True 235 | elif data[1:3] == b'\x03\x04': 236 | # Expected response for read_regs of 2 registers starting with REG_TH 237 | msg = struct.unpack('>3B3H',data) 238 | self.__thresh = float(msg[3+0]) 239 | self.__addr = msg[3+1] 240 | res = True 241 | elif data[1] == self.__FC_W_SING: 242 | # Expected response for write single reg 243 | # extract and format the response according to the register written 244 | # 0 1 2 3 4 5 245 | # [sa][06][ reg ][ val ][crc16] 246 | # 247 | msg = struct.unpack('>2B3H',data) 248 | if msg[0] == self.__REG_TH : 249 | self.__thresh = float(msg[2]) 250 | res = True 251 | elif msg[0] == self.__REG_ADDR: 252 | self.__addr = msg[2] 253 | res = True 254 | else: 255 | self.__dump('unknown valid response to 0x06 msg:',buf[:buflen]) 256 | elif data[1] == self.__FC_U_RESET or data[1] == self.__FC_U_CAL: 257 | # Expected response for user defined function code 258 | # 259 | # 0 1 2 3 260 | # [sa][fc][crc16] 261 | res = True 262 | else: 263 | self.__dump('unknown valid msg:',buf[:buflen]) 264 | else: 265 | self.__dump('bad checksum:',buf[:buflen]) 266 | elif len(buf) > 0: 267 | self.__dump('not enough data:',buf[:buflen]) 268 | return res 269 | 270 | def Poll(self): 271 | """ 272 | read data from the module and return it as a tuple 273 | 274 | """ 275 | 276 | pd = None 277 | if self.__cmd_read_regs(self.__SLAVEADD,self.__FC_R_INP,self.__REG_U,10): 278 | pd = self.PollData( 279 | Volt = self.__volt, 280 | Current = self.__current, 281 | Power = self.__power, 282 | Energy = self.__energy, 283 | Freq = self.__freq, 284 | Pf = self.__pf, 285 | Alarm = self.__alarm) 286 | return pd 287 | 288 | def PowerAlarm(self,Value = None): 289 | """ 290 | reads and/or sets the power alarm threshold 291 | 292 | """ 293 | res = None 294 | if Value == None: 295 | if self.__cmd_read_regs(self.__SLAVEADD,self.__FC_R_HOLD,self.__REG_TH,2): 296 | res = self.__thresh 297 | else: 298 | if (Value < 0) or (Value > 0x7fff): 299 | raise ValueError 300 | if self.__cmd_write_reg(self.__SLAVEADD,self.__REG_TH,int(round(Value,0))): 301 | res = self.__thresh 302 | return res 303 | 304 | def ResetEnergy(self): 305 | """ 306 | resets the energy counter 307 | 308 | """ 309 | res = self.__cmd_userfunc(self.__SLAVEADD,self.__FC_U_RESET) 310 | return res 311 | 312 | 313 | def __init__(self,ACMport=DEFPORT,ACMspeed=9600): 314 | self.__ACM = serial.Serial(port = ACMport, 315 | baudrate=ACMspeed, 316 | timeout = 0.01) 317 | 318 | 319 | if __name__ == "__main__": 320 | arg = parser.parse_args() 321 | 322 | ACM = AC_COMBOX(arg.port_dev) 323 | 324 | if arg.out_name=='!': 325 | out_name = 'ACM_'+strftime('%Y%m%d%H%M%S',localtime())+'.csv' 326 | else: 327 | out_name = arg.out_name 328 | 329 | if arg.reset: 330 | ACM.ResetEnergy() 331 | 332 | 333 | ACM.PowerAlarm(arg.alarm) 334 | 335 | 336 | f = open(out_name,'w') 337 | f.write('Time[S],Volt[V],Current[A],Power[W],Energy[Wh],Freq[Hz],PF, Alarm\n') 338 | start = perf_counter() 339 | now = perf_counter()-start 340 | try: 341 | while True: 342 | now = perf_counter()-start 343 | pd = ACM.Poll() 344 | s = '{:5.1f},{:4.1f},{:7.3f},{:5.1f},{:5.0f},{:3.1f},{:5.2f},{:1n}'.format( 345 | now, 346 | pd.Volt, 347 | pd.Current, 348 | pd.Power, 349 | pd.Energy, 350 | pd.Freq, 351 | pd.Pf, 352 | pd.Alarm) 353 | f.write(s+'\n') 354 | print(s) 355 | elapsed = (perf_counter()-start) - now 356 | if elapsed < arg.int_time: 357 | sleep(arg.int_time - elapsed) 358 | except KeyboardInterrupt: 359 | f.close() 360 | 361 | 362 | 363 | 364 | -------------------------------------------------------------------------------- /AC_USB_PowerMeter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | #MIT License 3 | # 4 | #Copyright (c) 2021 TheHWcave 5 | # 6 | #Permission is hereby granted, free of charge, to any person obtaining a copy 7 | #of this software and associated documentation files (the "Software"), to deal 8 | #in the Software without restriction, including without limitation the rights 9 | #to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | #copies of the Software, and to permit persons to whom the Software is 11 | #furnished to do so, subject to the following conditions: 12 | # 13 | #The above copyright notice and this permission notice shall be included in all 14 | #copies or substantial portions of the Software. 15 | # 16 | #THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | #IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | #FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | #AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | #LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | #OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | #SOFTWARE. 23 | # 24 | import tkinter as tk 25 | import tkinter.scrolledtext as tkst 26 | import tkinter.filedialog as tkfd 27 | import tkinter.messagebox as tkmb 28 | import tkinter.font as tkFont 29 | from collections import namedtuple 30 | from AC_COMBOX import AC_COMBOX 31 | from time import localtime,strftime,perf_counter_ns 32 | import math,argparse 33 | 34 | class AC_USB_PM_GUI(): 35 | 36 | 37 | 38 | def __init__(self,port,rec_averages = False): 39 | 40 | # create root window and frames 41 | self.window = tk.Tk() 42 | self.window.option_add('*Font','fixed') 43 | 44 | 45 | # give main loop a chance to run once before running (file) dialog 46 | # otherwise entry fields will loose focus and appear to hang 47 | self.window.update_idletasks() 48 | 49 | self.window.title("TheHWcave's AC USB Powermeter") 50 | 51 | # the overall structure is a 5 rows by 3 columns grid 52 | 53 | # (16) (16) 16) = 48 54 | # 0 1 2 55 | # 0 [ port ] 56 | # 1 [ rec ] 57 | # 2 [Volt] [Curr] [Pwr] ] 58 | # 3 [Freq] [Ener] [Pf ] ] 59 | # 4 [Q ] [S] [phi] ] 60 | 61 | 62 | 63 | 64 | 65 | 66 | # port stuff in row 0 67 | # (8) (32) (8) = 48 68 | # 0 1 2 69 | # 0 label eeeeeeeeeeeeeeeeeeeee button 70 | # 71 | # 72 | self.portframe = tk.Frame(self.window) 73 | 74 | self.labelPort = tk.Label(self.portframe,width=8, text= 'port:') 75 | self.entryPort = tk.Entry(self.portframe, width=32) 76 | self.buttConn = tk.Button(self.portframe,width=8,text='Connect',bd=5,command=self.DoConnect) 77 | self.entryPort.bind('',self.DoConnect) 78 | self.entryPort.insert(0,port) 79 | self.labelPort.grid(row=0,column=0,sticky='E') 80 | self.entryPort.grid(row=0,column=1,sticky='W') 81 | self.buttConn.grid(row=0,column=2,sticky='W') 82 | self.portframe.grid(row=0,column=0,columnspan=3) 83 | 84 | 85 | # rec stuff in row 1 86 | # 87 | # (4) (4) (8) (24) (8) = 48 88 | # 0 1 2 3 4 89 | # 0 x10 REC #recs fffffffffffffffffff menu 90 | # 91 | # 92 | self.recframe = tk.Frame(self.window) 93 | self.RecSpdList= ('0.5s','1s','2s','5s','10s','30s','1min','5min','10min','30min','1h') 94 | self.RecSpdSec = ( 0.5 , 1 , 2 , 5 , 10 , 30 ,60 ,300 ,600 ,1800 ,3600) 95 | self.RecSpd = 1 96 | self.RecNums = 0 97 | self.x10 = False 98 | self.RecSpdVal = tk.StringVar() 99 | self.RecSpdVal.set(self.RecSpdList[1]) 100 | self.optRecSpd = tk.OptionMenu(self.recframe,self.RecSpdVal,*self.RecSpdList,command=self.DoRecSpd) 101 | 102 | self.buttRec = tk.Button(self.recframe,text='Rec',bd=5,command=self.DoRec,width=3) 103 | self.buttx10 = tk.Button(self.recframe,text='x1', bd=5,command=self.Dox10,width=3) 104 | self.RecName = '' 105 | self.RecData = [[0.0,0.0,0] for x in range (9)] 106 | self.REC_VALUE = 0 107 | self.REC_SUM = 1 108 | self.REC_N = 2 109 | self.RecAve = rec_averages 110 | self.labelRecFn= tk.Label(self.recframe,text= '{:24s}'.format(self.RecName),width=24) 111 | self.labelRNums= tk.Label(self.recframe,text= '',width=8) 112 | 113 | self.buttx10.grid(row=0,column=0,sticky='W') 114 | self.buttRec.grid(row=0,column=1,sticky='W') 115 | self.labelRNums.grid(row=0,column=2,sticky='E') 116 | self.labelRecFn.grid(row=0,column=3,sticky='W') 117 | self.optRecSpd.grid(row=0,column=4,sticky='W') 118 | 119 | self.recframe.grid(row=1,column=0,columnspan=3) 120 | 121 | # data frames in rows 2 & 3 122 | # 6 data frames, arranged as a 2 x 3 grid. The grid positon is 123 | # defined in the FD structure. Each data frame looks like 124 | # 125 | # (5) (8) (3) = 16 126 | # 0 1 2 127 | # 0 label value unit 128 | # 129 | # 130 | 131 | FrameData = namedtuple('FrameData',('Attr','Row','Col','Label','Fmtx1','Fmtx10','Scale','Unit','Idx')) 132 | self.FD = [FrameData(Attr='Volt' ,Row=2,Col=0,Label='Volt',Fmtx1 ='{:7.1f}',Fmtx10 ='{:7.1f}',Scale = 1,Unit='V' ,Idx=0), 133 | FrameData(Attr='Current',Row=2,Col=1,Label='Curr',Fmtx1 ='{:7.3f}',Fmtx10 ='{:7.4f}',Scale =10,Unit='A' ,Idx=1), 134 | FrameData(Attr='Power' ,Row=2,Col=2,Label='Pwr ',Fmtx1 ='{:7.1f}',Fmtx10 ='{:7.2f}',Scale =10,Unit='W' ,Idx=2), 135 | FrameData(Attr='Pf' ,Row=3,Col=2,Label='Pf ',Fmtx1 ='{:7.2f}',Fmtx10 ='{:7.2f}',Scale = 1,Unit=' ' ,Idx=3), 136 | FrameData(Attr='Freq' ,Row=3,Col=0,Label='Freq',Fmtx1 ='{:7.1f}',Fmtx10 ='{:7.1f}',Scale = 1,Unit='Hz' ,Idx=4), 137 | FrameData(Attr='Energy' ,Row=3,Col=1,Label='Ener',Fmtx1 ='{:7.0f}',Fmtx10 ='{:7.1f}',Scale =10,Unit='Wh' ,Idx=5), 138 | FrameData(Attr='Q-pwr' ,Row=4,Col=0,Label='Qpwr',Fmtx1 ='{:7.3f}',Fmtx10 ='{:7.4f}',Scale =10,Unit='var',Idx=6), 139 | FrameData(Attr='S-pwr' ,Row=4,Col=1,Label='Spwr',Fmtx1 ='{:7.3f}',Fmtx10 ='{:7.4f}',Scale =10,Unit='VA' ,Idx=7), 140 | FrameData(Attr='Phi' ,Row=4,Col=2,Label='Phi ',Fmtx1 ='{:4.1f}',Fmtx10 ='{:4.1f}',Scale = 1,Unit='º' ,Idx=8)] 141 | 142 | 143 | self.dataframe = [] 144 | self.datalabel = [] 145 | self.datavalue = [] 146 | self.dataunit = [] 147 | for fd in self.FD: 148 | df = tk.Frame(self.window,borderwidth=4,relief='groove') 149 | dl = tk.Label(df,width=5,text=fd.Label) 150 | dl.grid(row=0,column=0,sticky='W') 151 | 152 | 153 | dv = tk.Label(df,width=8,text='-------') 154 | dv.grid(row=0,column=1,sticky='E') 155 | 156 | du = tk.Label(df,width=3,text=fd.Unit) 157 | du.grid(row=0,column=2,sticky='W') 158 | 159 | df.grid(row=fd.Row,column=fd.Col,sticky='W') 160 | 161 | 162 | self.dataframe.append(df) 163 | self.datalabel.append(dl) 164 | self.datavalue.append(dv) 165 | self.dataunit.append(du) 166 | 167 | 168 | # remaining intitalisation and start of main loop 169 | 170 | self.Module = None 171 | self.entryPort.focus_set() 172 | self.PollCount = 0 173 | self.ProgStart = perf_counter_ns() 174 | self.PollModule() 175 | tk.mainloop() 176 | if self.RecName != '': 177 | self.f.close() 178 | 179 | def DoConnect(self,event=None): 180 | """ 181 | given a port name, the function tries to connect. 182 | As a (crude) test if we connected to a PZEM004T it tries 183 | to read a data record. 184 | 185 | Note that once it connects successfully subsequent connects 186 | only reset the energy data 187 | 188 | """ 189 | port = self.entryPort.get() 190 | if self.Module == None: 191 | 192 | try: 193 | self.Module = AC_COMBOX(port) 194 | self.pd = self.Module.Poll() # try a read 195 | if self.pd == None: 196 | self.Module = None 197 | tkmb.showerror("device error","device at "+port+" does not respond") 198 | else: 199 | self.buttConn.config(relief='sunken') 200 | except: 201 | tkmb.showerror("port error","can't open "+port) 202 | self.Module = None 203 | self.buttConn.config(relief='raised') 204 | 205 | else: 206 | self.Module.ResetEnergy() 207 | 208 | def DoRecSpd(self,event=None): 209 | """ 210 | changes the recording speed 211 | """ 212 | idx = self.RecSpdList.index(self.RecSpdVal.get()) 213 | self.RecSpd = self.RecSpdSec[idx] 214 | 215 | def Dox10(self,event=None): 216 | """ 217 | changes the x1 / x10 mode. 218 | """ 219 | self.x10 = not self.x10 220 | if self.x10: 221 | self.buttx10.config(text='x10',relief='sunken') 222 | else: 223 | self.buttx10.config(text='x1',relief='raised') 224 | 225 | 226 | def DoRec(self,event=None): 227 | """ 228 | starts or stops the recording and shows the 229 | recording filename while recording is on. 230 | """ 231 | if self.Module != None: 232 | if self.RecName == '': 233 | self.RecName = 'REC_'+strftime('%Y%m%d%H%M%S',localtime())+'.csv' 234 | try: 235 | self.f = open(self.RecName,'w') 236 | self.f.write('Time[S],') 237 | for fd in self.FD: self.f.write(fd.Label+'['+fd.Unit+'],') 238 | self.f.write('xmode\n') 239 | for RD in self.RecData: 240 | RD[self.REC_SUM] = 0.0 241 | RD[self.REC_N] = 0 242 | 243 | self.buttRec.config(relief='sunken') 244 | self.PollCount = 0 245 | self.RecNums = 0 246 | self.labelRNums.config(text= '#{:7n}'.format(self.RecNums)) 247 | except: 248 | tkmb.showerror("rec error","can't create "+self.RecName) 249 | self.RecName = '' 250 | self.labelRNums.config(text= '') 251 | else: 252 | self.f.close() 253 | self.buttRec.config(relief='raised') 254 | self.RecName = '' 255 | self.labelRNums.config(text= '') 256 | self.labelRecFn.config(text= '{:24s}'.format(self.RecName)) 257 | 258 | 259 | def PollModule(self,event=None): 260 | """ 261 | polls the module every 0.5s. The time is adjusted to maintain accuracy 262 | """ 263 | if self.Module != None: 264 | 265 | self.PollCount += 0.5 266 | err = False 267 | try: 268 | self.pd = self.Module.Poll() 269 | except: 270 | err = True 271 | if err or self.pd == None: 272 | tkmb.showerror("comms error","lost connection ") 273 | self.window.quit() 274 | else: 275 | 276 | 277 | # calculate some useful values out of the measured data 278 | phi_rad = math.acos(self.pd.Pf) 279 | phi_deg = math.degrees(phi_rad) 280 | spwr = self.pd.Volt * self.pd.Current 281 | qpwr =spwr * math.sin(phi_rad) 282 | 283 | 284 | for i,fd in enumerate(self.FD): 285 | if i < 6: 286 | # values directly measured 287 | if self.x10: 288 | val = getattr(self.pd,fd.Attr) / fd.Scale 289 | s = fd.Fmtx10.format(val) 290 | 291 | else: 292 | val = getattr(self.pd,fd.Attr) 293 | s = fd.Fmtx1.format(val) 294 | else: 295 | # calculated values 296 | if fd.Attr == 'Phi': 297 | val =phi_deg 298 | s=fd.Fmtx1.format(val) 299 | elif fd.Attr == 'Q-pwr': 300 | val = qpwr 301 | if self.x10: 302 | val = val/fd.Scale 303 | s = fd.Fmtx10.format(val) 304 | else: 305 | s=fd.Fmtx1.format(val) 306 | elif fd.Attr == 'S-pwr': 307 | val = spwr 308 | if self.x10: 309 | val = val/fd.Scale 310 | s = fd.Fmtx10.format(val) 311 | else: 312 | s=fd.Fmtx1.format(val) 313 | else: 314 | s='???' # should never happen 315 | if self.RecName != '': 316 | self.RecData[fd.Idx][self.REC_VALUE] = val 317 | self.RecData[fd.Idx][self.REC_N] += 1 318 | self.RecData[fd.Idx][self.REC_SUM] += val 319 | 320 | self.datavalue[i].configure(text=s) 321 | 322 | 323 | if self.RecName != '': 324 | # for debug only 325 | # rs = '' 326 | # for RD in self.RecData: 327 | # rs += '{:9.5f}'.format(RD[self.REC_VALUE]) + ',' 328 | # s = '{:5n},{:s}{:1n}'.format(0,rs,0) 329 | # self.f.write(s+'\n') 330 | if self.PollCount % self.RecSpd == 0: 331 | rs = '' # for building a recording string 332 | for RD in self.RecData: 333 | if self.RecAve: 334 | rs += '{:9.5f}'.format(RD[self.REC_SUM] / RD[self.REC_N]) + ',' 335 | RD[self.REC_SUM] = 0.0 336 | RD[self.REC_N] = 0 337 | else: 338 | rs += '{:9.5f}'.format(RD[self.REC_VALUE]) + ',' 339 | s = '{:5n},{:s}{:1n}'.format(self.PollCount,rs,10 if self.x10 else 1) 340 | try: 341 | self.f.write(s+'\n') 342 | self.RecNums = self.RecNums +1 343 | self.labelRNums.config(text= '#{:7n}'.format(self.RecNums)) 344 | except: 345 | tkmb.showerror("rec error","can't write to "+self.RecName) 346 | self.RecName = '' 347 | self.labelRNums.config(text= '') 348 | self.labelRecFn.config(text= '{:24s}'.format(self.RecName)) 349 | self.buttRec.config(relief='raised') 350 | 351 | 352 | elapsed = (perf_counter_ns() - self.ProgStart)//1000000 # time in ms since start 353 | time2sleep= 500 - (elapsed % 500) 354 | self.window.after(time2sleep, self.PollModule) 355 | 356 | 357 | if __name__ == "__main__": 358 | 359 | parser = argparse.ArgumentParser() 360 | 361 | parser.add_argument('--port',help='port ', 362 | action='store',type=str,default='') 363 | parser.add_argument('--no_average',help='disables recording of averages',action="store_true") 364 | 365 | 366 | arg = parser.parse_args() 367 | 368 | gui = AC_USB_PM_GUI(arg.port,not arg.no_average) 369 | 370 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 TheHWcave 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PZEM004T-option2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheHWcave/Peacefair-PZEM-004T-/4e150e288f2733153d537237eb870e6af3c51365/PZEM004T-option2.pdf -------------------------------------------------------------------------------- /PZEM004T-orig.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheHWcave/Peacefair-PZEM-004T-/4e150e288f2733153d537237eb870e6af3c51365/PZEM004T-orig.pdf -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Peacefair-PZEM-004T- 2 | reverse-engineered schematics and interface software 3 | 4 | The PZEM-004T module is a mains powered module that measures voltage, current, power factor, apparent power and frequency. It has no display but provides these values over a serial interface. 5 | 6 | I have reverse-enineered the circuit in PZEM004T-orig.pdf 7 | The modification I have performed is in PZEM004-opton2.pdf 8 | 9 | The modification allows it to measure from 0VAC onwards and powers it from the serial interface instead of mains. This module uses AC mains directly. Use or modify it only if you are familiar with the necessary precautions. Use at your own risk! 10 | 11 | Edited to add: 12 | Regarding R17a and R17b in the modification: These should be rated for 350V. I used TE-connectivity type LR1F 0.6W 1% or Vishay MRS25 0.6W 1% . The combined value needs to match the original R17 which in my module was just 978K (instead of 1M). This is because the module has been factory-calibrated with the original R17. For the isolating (!!!!) DC-2-DC converter I used Recom 1W DC-DC converter 4.5-5.5V to 12V RS-Components stock# 828-9343, price £10.66 (incl VAT). This converter is more expensive than the usual isolating types but tested to 6.4KV (instead of the usual 1KV). The 1KV types are generally not designed to be exposed continuously to high voltage. 13 | 14 | There is a YouTube video about the modification, here: https://www.youtube.com/watch?v=qRsjsenvlJA 15 | 16 | The software will work with original or modified modules. Note that the x1/x10 feature in the software only makes sense if you use the current transformer with either a single wire passing through the core (x1 mode , range 0.02A to 100A), or 10 windings (x10 mode, range 0.002A to 10A) 17 | 18 | The AC_USB_PowerMeter.py contains the GUI. It needs the AC_COMBOX.py which contains the serial interface handler. Use Python3.8 or newer. The software has been tested on Linux and Windows 7. 19 | 20 | 21 | usage: AC_USB_PowerMeter.py [-h] [--port PORT] [--no_average] 22 | 23 | optional arguments: 24 | -h, --help show this help message and exit 25 | --port PORT port 26 | --no_average disables recording of averages 27 | 28 | --------------------------------------------------------------------------------