|
@@ -0,0 +1,295 @@ |
|
|
|
|
|
from StringIO import StringIO |
|
|
|
|
|
|
|
|
|
|
|
import collections |
|
|
|
|
|
import itertools |
|
|
|
|
|
import unittest |
|
|
|
|
|
|
|
|
|
|
|
class MeterRead(collections.namedtuple('MeterRead', [ 'meterts', 'readts', 'status', 'load', 'maxload', 'loadunit', 'sink', 'source', 'ssunit' ])): |
|
|
|
|
|
pass |
|
|
|
|
|
|
|
|
|
|
|
class ZoneInfo(collections.namedtuple('ZoneInfo', [ 'tz', 'lcl', 'utc', 'offset' ])): |
|
|
|
|
|
def getOffset(self): |
|
|
|
|
|
'''Return the offset in seconds from UTC. This value |
|
|
|
|
|
should be subtracted from UTC to get localtime or added |
|
|
|
|
|
to localtime to get UTC.''' |
|
|
|
|
|
|
|
|
|
|
|
off = int(self.offset) |
|
|
|
|
|
|
|
|
|
|
|
min = abs(off) % 100 |
|
|
|
|
|
hr = int(off / 100) |
|
|
|
|
|
|
|
|
|
|
|
return hr * 60 * 60 + min * 60 |
|
|
|
|
|
|
|
|
|
|
|
def _pread(fp, off, sz): |
|
|
|
|
|
origpos = fp.tell() |
|
|
|
|
|
|
|
|
|
|
|
fp.seek(off) |
|
|
|
|
|
r = fp.read(sz) |
|
|
|
|
|
|
|
|
|
|
|
fp.seek(origpos) |
|
|
|
|
|
|
|
|
|
|
|
return r |
|
|
|
|
|
|
|
|
|
|
|
# |
|
|
|
|
|
# Notes: |
|
|
|
|
|
# |
|
|
|
|
|
# We are assuming that the first line of the file is in the same timezone |
|
|
|
|
|
# as the line that immediately follows. We also assume that the remaining |
|
|
|
|
|
# lines in a file are the same timezone. This will be fine as long as no |
|
|
|
|
|
# one starts or stops the program around a time change. This is pretty safe |
|
|
|
|
|
# in that it only happens twice a year. This is a .023% chance that a |
|
|
|
|
|
# random reboot/start of the program will hit this. |
|
|
|
|
|
# |
|
|
|
|
|
# The meter updates the timezone info when it gets the new info. This |
|
|
|
|
|
# means that we don't know exactly when the time changed, BUT there will |
|
|
|
|
|
# be a discontinuity, where it skips forward or backward an hour, and this |
|
|
|
|
|
# is when the new timezone takes effect. |
|
|
|
|
|
|
|
|
|
|
|
class ParseLog(object): |
|
|
|
|
|
def __init__(self, fp): |
|
|
|
|
|
self._fp = fp |
|
|
|
|
|
|
|
|
|
|
|
@staticmethod |
|
|
|
|
|
def parseline(data, tz=None): |
|
|
|
|
|
p = data.split() |
|
|
|
|
|
if p[0] == 'm': |
|
|
|
|
|
if tz is None: |
|
|
|
|
|
raise ValueError('cannot parse meter line w/o timezone info') |
|
|
|
|
|
meterts = int(p[2]) - tz.getOffset() |
|
|
|
|
|
return MeterRead(meterts=meterts, readts=float(p[1]), |
|
|
|
|
|
status=p[3], load=float(p[4]), maxload=float(p[5]), |
|
|
|
|
|
loadunit=p[6], sink=float(p[7]), |
|
|
|
|
|
source=float(p[8]), ssunit=p[9]) |
|
|
|
|
|
elif p[0] == 'z': |
|
|
|
|
|
return ZoneInfo(p[1], int(p[2]), int(p[3]), p[4]) |
|
|
|
|
|
else: |
|
|
|
|
|
raise ValueError('unknown type: %s' % repr(data[0])) |
|
|
|
|
|
|
|
|
|
|
|
def __iter__(self): |
|
|
|
|
|
# this can be suspended/resumed between yields, so |
|
|
|
|
|
# keep track of pointer. |
|
|
|
|
|
|
|
|
|
|
|
fp = self._fp |
|
|
|
|
|
|
|
|
|
|
|
pos = 0 |
|
|
|
|
|
tz = None |
|
|
|
|
|
done = False |
|
|
|
|
|
while not done: |
|
|
|
|
|
fp.seek(pos) |
|
|
|
|
|
pendinglines = [] |
|
|
|
|
|
|
|
|
|
|
|
# find the timezone info |
|
|
|
|
|
while True: |
|
|
|
|
|
l = fp.readline() |
|
|
|
|
|
if l == '': |
|
|
|
|
|
done = True |
|
|
|
|
|
break |
|
|
|
|
|
elif l[0] == 'z': |
|
|
|
|
|
nexttz = self.parseline(l) |
|
|
|
|
|
if tz is None: |
|
|
|
|
|
tz = nexttz |
|
|
|
|
|
else: |
|
|
|
|
|
pendinglines.append(l) |
|
|
|
|
|
|
|
|
|
|
|
pos = fp.tell() |
|
|
|
|
|
|
|
|
|
|
|
# was there a tz change |
|
|
|
|
|
tzchange = tz.offset != nexttz.offset |
|
|
|
|
|
lastts = None |
|
|
|
|
|
for idx, i in enumerate(pendinglines): |
|
|
|
|
|
line = self.parseline(i, tz) |
|
|
|
|
|
if (tzchange and lastts is not None and |
|
|
|
|
|
abs(line.meterts - lastts) > 50*60): |
|
|
|
|
|
tz = nexttz |
|
|
|
|
|
# need to reparse due to zone changed |
|
|
|
|
|
line = self.parseline(i, tz) |
|
|
|
|
|
tzchange = False |
|
|
|
|
|
|
|
|
|
|
|
yield line |
|
|
|
|
|
lastts = line.meterts |
|
|
|
|
|
|
|
|
|
|
|
@staticmethod |
|
|
|
|
|
def verifyIndex(idx, fp): |
|
|
|
|
|
origpos = fp.tell() |
|
|
|
|
|
|
|
|
|
|
|
# check size |
|
|
|
|
|
fp.seek(0, 2) # End of file |
|
|
|
|
|
length = fp.tell() == idx['length'] |
|
|
|
|
|
|
|
|
|
|
|
fp.seek(origpos) |
|
|
|
|
|
|
|
|
|
|
|
return all(itertools.chain((length,), (_pread(fp, x[1], 1) == 'z' for x in idx['index']))) |
|
|
|
|
|
|
|
|
|
|
|
@staticmethod |
|
|
|
|
|
def generateIndex(fp, bufsize=65536): |
|
|
|
|
|
origpos = fp.tell() |
|
|
|
|
|
|
|
|
|
|
|
fp.seek(0, 2) # End of file |
|
|
|
|
|
idxs = [] |
|
|
|
|
|
ret = dict(length=fp.tell(), index=idxs) |
|
|
|
|
|
|
|
|
|
|
|
pos = 0 |
|
|
|
|
|
fp.seek(0) |
|
|
|
|
|
prebuf = '' |
|
|
|
|
|
while True: |
|
|
|
|
|
buf = prebuf + fp.read(bufsize) |
|
|
|
|
|
if buf == '': |
|
|
|
|
|
break |
|
|
|
|
|
|
|
|
|
|
|
cont = False |
|
|
|
|
|
zpos = 0 |
|
|
|
|
|
while True: |
|
|
|
|
|
try: |
|
|
|
|
|
idx = buf.index('z', zpos) |
|
|
|
|
|
except ValueError: |
|
|
|
|
|
break |
|
|
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
nl = buf.index('\n', idx) |
|
|
|
|
|
except ValueError: |
|
|
|
|
|
prebuf = buf |
|
|
|
|
|
cont = True |
|
|
|
|
|
break |
|
|
|
|
|
|
|
|
|
|
|
line = ParseLog.parseline(buf[idx:nl], None) |
|
|
|
|
|
|
|
|
|
|
|
idxs.append((line.utc, pos + idx)) |
|
|
|
|
|
|
|
|
|
|
|
zpos = nl + 1 |
|
|
|
|
|
|
|
|
|
|
|
if cont: |
|
|
|
|
|
continue |
|
|
|
|
|
|
|
|
|
|
|
pos += bufsize + len(prebuf) |
|
|
|
|
|
prebuf = '' |
|
|
|
|
|
|
|
|
|
|
|
# restore position |
|
|
|
|
|
fp.seek(origpos) |
|
|
|
|
|
|
|
|
|
|
|
return ret |
|
|
|
|
|
|
|
|
|
|
|
@classmethod |
|
|
|
|
|
def fromfile(cls, fp): |
|
|
|
|
|
'''Pass in a file like object that is the log. |
|
|
|
|
|
|
|
|
|
|
|
Note that this will claim ownership of the file object. It |
|
|
|
|
|
may seek and read parts of the file, the fp should not be |
|
|
|
|
|
accessed after this call. When the object is destroyed, it |
|
|
|
|
|
will be closed. |
|
|
|
|
|
''' |
|
|
|
|
|
|
|
|
|
|
|
return cls(fp) |
|
|
|
|
|
|
|
|
|
|
|
class MiscTests(unittest.TestCase): |
|
|
|
|
|
def test_pread(self): |
|
|
|
|
|
s = 'this is a random test string' |
|
|
|
|
|
sio = StringIO(s) |
|
|
|
|
|
|
|
|
|
|
|
pos = 13 |
|
|
|
|
|
sio.seek(pos) |
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(_pread(sio, 10, 4), s[10:10 + 4]) |
|
|
|
|
|
self.assertEqual(_pread(sio, 7, 1), s[7]) |
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(sio.tell(), pos) |
|
|
|
|
|
|
|
|
|
|
|
class Tests(unittest.TestCase): |
|
|
|
|
|
oldlines = '''l 1571848576 Connected 1.1260 9.155000 W 65375.946 0.000 Wh |
|
|
|
|
|
z GMT+7 1571848569 1571873769 -0700 |
|
|
|
|
|
l 1571848585 Connected 1.0890 9.155000 kW 15.946 0.000 kWh |
|
|
|
|
|
l 1571848593 Connected 1.0500 9.155000 kW 15.946 0.000 kWh |
|
|
|
|
|
''' |
|
|
|
|
|
def test_getoffset(self): |
|
|
|
|
|
zi = ZoneInfo(tz='GMT+8', lcl='1577132476', utc='1577161276', offset='-0800') |
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(zi.getOffset(), -8 * 60 * 60) |
|
|
|
|
|
|
|
|
|
|
|
zi = ZoneInfo(tz='GMT+8', lcl='1577132476', utc='1577161276', offset='-0700') |
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(zi.getOffset(), -7 * 60 * 60) |
|
|
|
|
|
|
|
|
|
|
|
zi = ZoneInfo(tz='GMT+8', lcl='1577132476', utc='1577161276', offset='-0730') |
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(zi.getOffset(), -(7 * 60 * 60 + 30 * 60)) |
|
|
|
|
|
|
|
|
|
|
|
# notes: |
|
|
|
|
|
# 1572767994 Sun Nov 3 00:59:54 PDT 2019 |
|
|
|
|
|
# 1572768005 Sun Nov 3 01:00:05 PDT 2019 |
|
|
|
|
|
# 1572771545 Sun Nov 3 01:59:05 PDT 2019 |
|
|
|
|
|
# 1572772464 Sun Nov 3 01:14:24 PST 2019 |
|
|
|
|
|
# 1572772472 Sun Nov 3 01:14:32 PST 2019 |
|
|
|
|
|
# 1572775140 Sun Nov 3 01:59:00 PST 2019 |
|
|
|
|
|
# 1572778799 Sun Nov 3 02:59:59 PST 2019 |
|
|
|
|
|
# 1572793200 Sun Nov 3 07:00:00 PST 2019 |
|
|
|
|
|
# 1572793208 Sun Nov 3 07:00:08 PST 2019 |
|
|
|
|
|
|
|
|
|
|
|
zonelines = '''m 1572767994.3 1572742790 Connected 0 0 kW 0 0 kWh |
|
|
|
|
|
z GMT+7 1572742795 1572767995 -0700 |
|
|
|
|
|
m 1572768005.3 1572742805 Connected 0 0 kW 0 0 kWh |
|
|
|
|
|
m 1572769505.3 1572744305 Connected 0 0 kW 0 0 kWh |
|
|
|
|
|
m 1572771545.3 1572746345 Connected 0 0 kW 0 0 kWh |
|
|
|
|
|
m 1572772464.3 1572747264 Connected 0 0 kW 0 0 kWh |
|
|
|
|
|
m 1572772472.3 1572743672 Connected 0 0 kW 0 0 kWh |
|
|
|
|
|
m 1572775140.3 1572746340 Connected 0 0 kW 0 0 kWh |
|
|
|
|
|
m 1572778799.3 1572749999 Connected 0 0 kW 0 0 kWh |
|
|
|
|
|
z GMT+8 1572750100 1572778900 -0800 |
|
|
|
|
|
m 1572793208.3 1572764405 Connected 0 0 kW 0 0 kWh |
|
|
|
|
|
''' |
|
|
|
|
|
|
|
|
|
|
|
def test_zonemove(self): |
|
|
|
|
|
# test when DST moves |
|
|
|
|
|
s = StringIO(self.zonelines) |
|
|
|
|
|
pl = ParseLog.fromfile(s) |
|
|
|
|
|
|
|
|
|
|
|
lines = list(pl) |
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual([ x.meterts for x in lines ], [ 1572767990, 1572768005, 1572769505, 1572771545, 1572772464, 1572772472, 1572775140, 1572778799, 1572793205 ]) |
|
|
|
|
|
|
|
|
|
|
|
def test_genverifindex(self): |
|
|
|
|
|
s = StringIO(self.zonelines) |
|
|
|
|
|
pos = 10 |
|
|
|
|
|
s.seek(pos) |
|
|
|
|
|
genidx = ParseLog.generateIndex(s, 2) |
|
|
|
|
|
|
|
|
|
|
|
zpos = [ i for i, x in enumerate(self.zonelines) if x == 'z' ] |
|
|
|
|
|
|
|
|
|
|
|
# that the position remained the same |
|
|
|
|
|
self.assertEqual(s.tell(), pos) |
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(genidx['length'], len(s.getvalue())) |
|
|
|
|
|
self.assertEqual(genidx['index'], [ (1572767995, zpos[0]), (1572778900, zpos[1]) ]) |
|
|
|
|
|
|
|
|
|
|
|
self.assertTrue(ParseLog.verifyIndex(genidx, s)) |
|
|
|
|
|
|
|
|
|
|
|
s.seek(pos) |
|
|
|
|
|
|
|
|
|
|
|
tmp = genidx.copy() |
|
|
|
|
|
tmp['length'] = 0 |
|
|
|
|
|
self.assertFalse(ParseLog.verifyIndex(tmp, s)) |
|
|
|
|
|
|
|
|
|
|
|
tmp = genidx.copy() |
|
|
|
|
|
tmp['index'][0] = (tmp['index'][0][0], 10) |
|
|
|
|
|
self.assertFalse(ParseLog.verifyIndex(tmp, s)) |
|
|
|
|
|
|
|
|
|
|
|
# that the position remained the same |
|
|
|
|
|
self.assertEqual(s.tell(), pos) |
|
|
|
|
|
|
|
|
|
|
|
newlines = '''m 1577161278.22 1577132472 Connected 0.2580 1.992000 kW 90.404 1.660 kWh |
|
|
|
|
|
z GMT+8 1577132476 1577161276 -0800 |
|
|
|
|
|
m 1577161288.39 1577132480 Connected 0.3410 1.992000 kW 90.404 1.660 kWh |
|
|
|
|
|
m 1577161298.96 1577132488 Connected 0.1450 1.992000 kW 90.404 1.660 kWh |
|
|
|
|
|
''' |
|
|
|
|
|
|
|
|
|
|
|
def test_parsenew(self): |
|
|
|
|
|
s = StringIO(self.newlines) |
|
|
|
|
|
pl = ParseLog.fromfile(s) |
|
|
|
|
|
|
|
|
|
|
|
lines = list(pl) |
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual([ x.readts for x in lines ], [ 1577161278.22, 1577161288.39, 1577161298.96 ]) |
|
|
|
|
|
self.assertEqual([ x.meterts for x in lines ], [ 1577161272, 1577161280, 1577161288 ]) |
|
|
|
|
|
self.assertEqual([ x.load for x in lines ], [ 0.2580, 0.3410, 0.1450 ]) |
|
|
|
|
|
|
|
|
|
|
|
def test_close(self): |
|
|
|
|
|
# test to make sure the file object is closed |
|
|
|
|
|
pass |