├── .gitignore ├── LICENSE ├── printers.py ├── defs.py ├── Format.py ├── README.md └── el4000.py /.gitignore: -------------------------------------------------------------------------------- 1 | .*.sw? 2 | .sw? 3 | __pycache__ 4 | *.pyo 5 | *.pyc 6 | *.bin 7 | *.BIN 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Peter Wu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /printers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Output formatters (for printing info and data files) 3 | # 4 | # Copyright (C) 2014 Peter Wu 5 | 6 | import math 7 | from defs import info, data 8 | 9 | # Python 2.7 compatibility 10 | if b'' == '': 11 | import functools, itertools 12 | iterbytes = functools.partial(itertools.imap, ord) 13 | else: 14 | iterbytes = iter 15 | 16 | class BasePrinter(object): 17 | """Prints the info, data header or data in verbose form.""" 18 | def __init__(self, filename): 19 | pass 20 | def print_info(self, t): 21 | print_namedtuple(t, info) 22 | def print_data_header(self, t): 23 | print_namedtuple(t, data) 24 | def print_data(self, t, date): 25 | print_namedtuple(t, data) 26 | 27 | class RawPrinter(BasePrinter): 28 | """Prints raw bytes in hex form, possibly with headers.""" 29 | def print_data(self, t, date): 30 | # Convert interpreted numbers back to bytes... 31 | all_bs = [data.pack_as_bytes(name, getattr(t, name)) 32 | for name in data.names] 33 | # Convert bytes to hex and print them 34 | print(date + ' ' + ' '.join( 35 | ''.join('{0:02x}'.format(b) for b in iterbytes(bs)) 36 | for bs in all_bs)) 37 | 38 | class CSVPrinter(BasePrinter): 39 | """Prints data separated by a semicolon.""" 40 | def __init__(self, filename, separator=','): 41 | self.separator = separator 42 | self.printed_header = False 43 | def print_data_header(self, t): 44 | pass 45 | def print_data(self, t, date): 46 | if not self.printed_header: 47 | print(self.separator.join(["timestamp"] + data.names)) 48 | self.printed_header = True 49 | print('{1}{0}{2:5.1f}{0}{3:5.3f}{0}{4:5.3f}' 50 | .format(self.separator, date, *t)) 51 | 52 | class EffectivePowerPrinter(BasePrinter): 53 | """ 54 | Prints the effective power in Watt, computed from voltage, current and the 55 | power factor. 56 | """ 57 | def __init__(self, filename, separator=','): 58 | self.separator = separator 59 | def print_data_header(self, t): 60 | pass 61 | def print_data(self, t, date): 62 | effective_power = t.voltage * t.current * t.power_factor 63 | print('{1}{0}{2:.1f}'.format(self.separator, date, effective_power)) 64 | 65 | class ApparentPowerPrinter(BasePrinter): 66 | """Prints the calculated apparent power in VA.""" 67 | def __init__(self, filename, separator=','): 68 | self.separator = separator 69 | def print_data_header(self, t): 70 | pass 71 | def print_data(self, t, date): 72 | apparent_power = t.voltage * t.current 73 | print('{1}{0}{2:.1f}'.format(self.separator, date, apparent_power)) 74 | 75 | def round_up(n, multiple): 76 | return int(math.ceil(1.0 * n / multiple) * multiple) 77 | 78 | def print_namedtuple(t, formatter): 79 | # Align at columns of four chars with at least two spaces as separator 80 | name_width = round_up(max(len(name) for name in t._fields) + 2, 4) 81 | format = '{0:' + str(name_width) + '}{1}' 82 | 83 | for n, v in zip(t._fields, t): 84 | # Print literals in displayable characters 85 | if isinstance(v, bytes): 86 | v = repr(v) 87 | print(format.format(n, formatter.unitify(n, v))) 88 | 89 | -------------------------------------------------------------------------------- /defs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Definitions for Voltcraft Energy Logger binary files 3 | # 4 | # Copyright (C) 2014 Peter Wu 5 | 6 | from Format import Format, Float10, Float100, Float1000, BCDFloat 7 | 8 | # Specification is available at 9 | # http://wiki.td-er.nl/index.php?title=Energy_Logger_3500 10 | # http://www2.produktinfo.conrad.com/datenblaetter/125000-149999/125323-da-01-en-Datenprotokoll_SD_card_file_Formatv1_2.pdf 11 | 12 | # common fields for Info and Setup 13 | def _add_date_time_fields(formatter, prefix=''): 14 | formatter.add_number(1, prefix + 'time_hour', values=range(0, 23)) 15 | formatter.add_number(1, prefix + 'time_minute', values=range(0, 59)) 16 | formatter.add_number(1, prefix + 'date_month', values=range(1, 12)) 17 | formatter.add_number(1, prefix + 'date_day', values=range(1, 31)) 18 | formatter.add_number(1, prefix + 'date_year', values=range(0, 99)) 19 | 20 | info = Format('Info') 21 | info.add_literal(b'INFO:', 'header_magic') 22 | info.add_number(3, 'total_power_consumption', type=Float1000, unit='kWh') 23 | info.add_number(3, 'total_recorded_time', type=Float100, unit='h') 24 | info.add_number(3, 'total_on_time', type=Float100, unit='h') 25 | for day in range(0, 10): 26 | name = 'total_kwh_today_min_{0}'.format(day) 27 | info.add_number(3, name, type=Float1000, unit='kWh') 28 | for day in range(0, 10): 29 | name = 'total_recorded_time_today_min_{0}'.format(day) 30 | info.add_number(2, name, type=Float100, unit='h') 31 | for day in range(0, 10): 32 | name = 'total_on_time_today_min_{0}'.format(day) 33 | info.add_number(2, name, type=Float100, unit='h') 34 | info.add_number(1, 'unit_id', values=range(0, 9)) 35 | info.add_number(4, 'tariff1', type=BCDFloat) 36 | info.add_number(4, 'tariff2', type=BCDFloat) 37 | _add_date_time_fields(info, 'init_') 38 | info.add_literal(4 * b'\xFF', 'end_of_file_code') 39 | info.build(102) 40 | 41 | 42 | data_hdr = Format('DataHeader') 43 | STARTCODE = b'\xe0\xc5\xea' 44 | data_hdr.add_literal(STARTCODE, 'startcode') 45 | # "Date of recording" MM/DD/YY 46 | data_hdr.add_number(1, 'record_month', values=range(1, 12)) 47 | data_hdr.add_number(1, 'record_day', values=range(1, 31)) 48 | data_hdr.add_number(1, 'record_year', values=range(0, 99)) 49 | # "Time of recording" HH:MM 50 | data_hdr.add_number(1, 'record_hour', values=range(0, 23)) 51 | data_hdr.add_number(1, 'record_minute', values=range(0, 59)) 52 | data_hdr.build(8) 53 | 54 | 55 | data = Format('Data') 56 | # Average voltage, average current, power factor (cos(phi)) 57 | data.add_number(2, 'voltage', type=Float10, unit='V', 58 | # Arbitrary values for sanity. 59 | values=range(2100, 2500)) 60 | data.add_number(2, 'current', type=Float1000, unit='A', 61 | # While permitted, 920 Watt is quite rare, so assume invalid. 62 | values=range(0, 4000)) 63 | data.add_number(1, 'power_factor', type=Float100) 64 | data.build(5) 65 | 66 | 67 | # save as 'setupel3.bin' (lowercase) 68 | setup = Format('Setup') 69 | SETUP_MAGIC = b'\xb8\xad\xf2' 70 | setup.add_literal(SETUP_MAGIC, 'header_magic') 71 | setup.add_number(1, 'unit_id', values=range(0, 9)) 72 | setup.add_number(1, 'hour_format', values=[1, 2]) # 1 = 12h, 2 = 24h 73 | setup.add_number(1, 'date_format', values=[1, 2]) # 1 = mm/dd/yy, 2 = dd/mm/yy 74 | _add_date_time_fields(setup) 75 | setup.add_number(1, 'currency', values=[1, 2, 4, 8]) # pound, SFr, dollar, euro 76 | setup.add_number(4, 'tariff1', type=BCDFloat) 77 | setup.add_number(4, 'tariff2', type=BCDFloat) 78 | setup.build(20) 79 | 80 | # Unused for now, it is supposed to interpret file names 81 | def decode_filename(name): 82 | name = name.upper() 83 | if not (name[0] >= 'A' and name[0] <= 'J'): 84 | raise ValueError('Invalid name {0}'.format(name)) 85 | unit_id = ord(name[0]) - 'A' 86 | # seconds since some time 87 | time_date = int(name[1:], 16) 88 | return name, time_date 89 | -------------------------------------------------------------------------------- /Format.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Helpers for interpreting binary data 3 | # 4 | # Copyright (C) 2014 Peter Wu 5 | 6 | import struct 7 | from collections import namedtuple 8 | import logging 9 | 10 | _logger = logging.getLogger(__name__) 11 | 12 | class Format(object): 13 | """ 14 | Helper around struct that provides named fields, conversion possibilities 15 | (by specifying a type) and validation possibilities. 16 | """ 17 | def __init__(self, label): 18 | self.label = label 19 | self.struct = None 20 | self.names = [] 21 | self.units = {} 22 | self.fmt = '!' 23 | self.field_structs = {} 24 | # Type that provides encoding and decoding methods 25 | self.value_types = {} 26 | # Names of fields which must be converted from byte to int 27 | self.int3s = [] 28 | self.literals = {} 29 | # If a name exists, it is the complete set of allowed values (after 30 | # unpacking from file, but before type conversion) 31 | self.valid_values = {} 32 | 33 | def _add_field(self, name, field): 34 | if self.struct: 35 | raise RuntimeError('Already initialized') 36 | if name in self.names: 37 | raise ValueError('Duplicate field key {0}'.format(name)) 38 | self.names.append(name) 39 | self.fmt += field 40 | self.field_structs[name] = struct.Struct('!' + field) 41 | 42 | def add_literal(self, data, name): 43 | field = '{0}s'.format(len(data)) 44 | self._add_field(name, field) 45 | self.literals[name] = data 46 | 47 | def add_number(self, size, name, type=None, unit='', values=None): 48 | int_types = ['B', 'H', '3s', 'I'] 49 | self._add_field(name, int_types[size - 1]) 50 | if size == 3: 51 | self.int3s.append(name) 52 | if type: 53 | self.value_types[name] = type 54 | self.units[name] = unit if unit else '' 55 | if values: 56 | self.valid_values[name] = values 57 | 58 | def build(self, asserted_size): 59 | if self.struct: 60 | raise RuntimeError('Already initialized') 61 | new_struct = struct.Struct(self.fmt) 62 | if asserted_size != new_struct.size: 63 | raise RuntimeError('Size mismatch: {0} != {1}' 64 | .format(asserted_size, new_struct.size)) 65 | # struct is passes the specification, save it! 66 | self.struct = new_struct 67 | self.factory = namedtuple(self.label, ' '.join(self.names)) 68 | 69 | def unitify(self, name, value): 70 | if not self.struct: 71 | raise RuntimeError('Not initialized yet') 72 | if name in self.units: 73 | unit = self.units[name] 74 | if unit == 'h': 75 | mins = int((value % 1.0) * 60) 76 | return '{0}h {1:02}m'.format(int(value), mins) 77 | elif unit: 78 | return '{0} {1}'.format(value, unit) 79 | return str(value) 80 | 81 | def unpack_field(self, name, val, validate=True): 82 | # Handle ints made from 3 bytes 83 | if name in self.int3s: 84 | val, = struct.unpack('!I', b'\x00' + val) 85 | if validate: 86 | # Literals must exactly match 87 | if name in self.literals: 88 | if self.literals[name] != val: 89 | raise RuntimeError('Literal mismatch: {0} != {1}' 90 | .format(repr(self.literals[name]), val)) 91 | # When reading from file, just warn 92 | if name in self.valid_values: 93 | if not val in self.valid_values[name]: 94 | _logger.info('Garbage value found for {0}.{1}: {2}' 95 | .format(self.label, name, val)) 96 | # Convert value according to its type 97 | if name in self.value_types: 98 | val = self.value_types[name].decode(val) 99 | return val 100 | 101 | def pack_field(self, name, val): 102 | # Convert value according to its type 103 | if name in self.value_types: 104 | val = self.value_types[name].encode(val) 105 | 106 | # Literals must match exactly, ignore given value 107 | if name in self.literals: 108 | val = self.literals[name] 109 | else: 110 | # pack works with integers, not floats. 111 | val = int(val) 112 | 113 | # Validate new data 114 | if name in self.valid_values: 115 | if not val in self.valid_values[name]: 116 | _logger.warn('Invalid value {0} for name {1}' 117 | .format(val, name)) 118 | 119 | # Numbers of 3 bytes are stored as bytes 120 | if name in self.int3s: 121 | val = struct.pack('!I', val)[-3:] 122 | return val 123 | 124 | def pack_as_bytes(self, name, val): 125 | num = self.pack_field(name, val) 126 | return self.field_structs[name].pack(num) 127 | 128 | def unpack(self, data, validate=True): 129 | """ 130 | Interprets the data according to this format, optionally with data 131 | validation. 132 | """ 133 | if not self.struct: 134 | raise RuntimeError('Not initialized yet') 135 | 136 | res = self.struct.unpack(data) 137 | unpacked_bytes = [] 138 | for name, val in zip(self.names, res): 139 | unpacked_bytes.append(self.unpack_field(name, val, validate)) 140 | 141 | return self.factory._make(unpacked_bytes) 142 | 143 | def pack(self, t): 144 | """ 145 | Formats the contents of a named tuple or dict according to this format. 146 | """ 147 | if not self.struct: 148 | raise RuntimeError('Not initialized yet') 149 | 150 | vals = [] 151 | for name in self.names: 152 | if isinstance(t, dict): 153 | val = t[name] 154 | else: 155 | val = getattr(t, name) 156 | vals.append(self.pack_field(name, val)) 157 | 158 | return self.struct.pack(*vals) 159 | 160 | def parse_from_file(self, f): 161 | data = f.read(self.size()) 162 | if not data: 163 | return None 164 | if len(data) != self.size(): 165 | raise RuntimeError('Short data read: ' + len(data)) 166 | return self.unpack(data) 167 | 168 | def size(self): 169 | if not self.struct: 170 | raise RuntimeError('Not initialized yet') 171 | return self.struct.size 172 | 173 | # Types that reinterprets data for display. 174 | # decode: file -> display; encode: display -> file 175 | class Float10(object): 176 | """Interprets the number 1234 (read from file) as 123.4.""" 177 | _factor = 10.0 178 | 179 | @classmethod 180 | def encode(cls, value): 181 | return int(value * cls._factor) 182 | 183 | @classmethod 184 | def decode(cls, value): 185 | return value / cls._factor 186 | 187 | class Float100(Float10): 188 | """Interprets the number 1234 (read from file) as 12.34.""" 189 | _factor = 100.0 190 | 191 | class Float1000(Float10): 192 | """Interprets the number 1234 (read from file) as 1.234.""" 193 | _factor = 1000.0 194 | 195 | class BCDFloat(object): 196 | """Interprets the number 0x01020304 (read from file) as 1.234.""" 197 | @staticmethod 198 | def encode(value): 199 | n = 0 200 | for i in range(0, 4): 201 | n += int(value / (10 ** -i) % 10) << (8 * (3 - i)) 202 | return n 203 | 204 | @staticmethod 205 | def decode(value): 206 | n = 0 207 | for i in range(0, 4): 208 | n += ((value >> 8 * (3 - i)) & 0xFF) * (10 ** -i) 209 | return n 210 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Energy Logger 4000 utility 2 | 3 | This project provides a utility which can be used to read info and binary logs 4 | from the Voltcraft Energy Logger 4000 as [sold by Conrad][conrad]. A setup file 5 | (setupel3.bin) can also be read and written, this allows you to configure the 6 | device via the SD card. 7 | 8 | ## Requirements 9 | 10 | To use this program, you will need the following: 11 | 12 | - A Python interpreter (version 2 or 3). Almost all Linux distributions have 13 | this already installed. For Mac OS X and Windows users, see [Pythons download 14 | page][python]. 15 | - (Recommended) A SecureDigital card to communicate with the EL4000. The EL4000 16 | manual recommends a 4 GB card which which works fine for me. 17 | - (Recommended) A Voltcraft Energy Logger 4000. It should also work with a 18 | EL3500 since it has the same file format, but I could not test this. 19 | 20 | This program has been tested with a Voltcraft Energy Logger 4000F (with a French 21 | power plug and a German adapter, bought via eBay) on a Dutch energy network. 22 | 23 | ## Usage 24 | 25 | Since this program is a console application, you need to open a terminal (or 26 | cmd) first. Available options: 27 | 28 | $ python el4000.py --help 29 | usage: el4000.py [-h] [-p {raw,base,watt,va,csv}] [-d DELIMITER] [-v] 30 | [-s [key=value [key=value ...]]] [-o] 31 | binfile [binfile ...] 32 | 33 | Energy Logger 4000 utility 34 | 35 | positional arguments: 36 | binfile info or data files (.bin) from SD card. If --setup is 37 | given, then this is the output file (and input for 38 | defaults). The order of files are significant when a 39 | timestamp is involved 40 | 41 | optional arguments: 42 | -h, --help show this help message and exit 43 | -p {raw,base,watt,va,csv}, --printer {raw,base,watt,va,csv} 44 | Output formatter (default 'base') 45 | -d DELIMITER, --delimiter DELIMITER 46 | Output delimiter for CSV output (default ',') 47 | -v, --verbose Increase logging level (twice for extra verbose) 48 | -s [key=value [key=value ...]], --setup [key=value [key=value ...]] 49 | Process a setupel3.bin file. Optional parameters can 50 | be given to set a field (-s unit_id=1 for example). If 51 | no parameters are given, the current values are 52 | printed 53 | 54 | ### Example: print time and watt as CSV 55 | 56 | Given a data file `A0810702.BIN`, you can write a `results.csv` file with: 57 | 58 | $ python el4000.py -p csv A0810702.BIN > results.csv 59 | 60 | Its content may look like: 61 | 62 | timestamp,voltage,current,power_factor 63 | 2014-06-27 13:13,237.1,0.215,0.420 64 | 2014-06-27 13:14,236.5,0.206,0.420 65 | 2014-06-27 13:15,235.7,0.199,0.420 66 | 2014-06-27 13:16,237.3,0.204,0.420 67 | ... 68 | 69 | If you happen to see "1970-01-01" as timestamp, be sure to include the info 70 | files (102 bytes) before others (and use `--data-only` to hide the contents of 71 | this info file). Compare: 72 | 73 | $ python el4000.py -p csv A07EF88B.BIN 74 | timestamp,voltage,current,power_factor 75 | 1970-01-01 00:00,238.5,0.000,0.000 76 | 1970-01-01 00:01,239.5,0.000,0.000 77 | ... 78 | $ python el4000.py -p csv --data-only A07EF88A.BIN A07EF88B.BIN 79 | timestamp,voltage,current,power_factor 80 | 2014-06-25 16:53,238.5,0.000,0.000 81 | 2014-06-25 16:54,239.5,0.000,0.000 82 | ... 83 | 84 | ### Example: show information file 85 | 86 | The information file is 102 bytes, its contents can be examined just like a data 87 | file: 88 | 89 | $ python el4000.py A0810701.BIN 90 | header_magic b'INFO:' 91 | total_power_consumption 0.534 kWh 92 | total_recorded_time 2119h 04m 93 | total_on_time 2115h 15m 94 | total_kwh_today_min_0 0.1 kWh 95 | ... 96 | total_kwh_today_min_9 0.0 kWh 97 | total_recorded_time_today_min_0 3h 42m 98 | ... 99 | total_recorded_time_today_min_9 0h 00m 100 | total_on_time_today_min_0 3h 42m 101 | ... 102 | total_on_time_today_min_9 0h 00m 103 | unit_id 0 104 | tariff1 0.221 105 | tariff2 0.227 106 | init_time_hour 16 107 | init_time_minute 53 108 | init_date_month 6 109 | init_date_day 25 110 | init_date_year 14 111 | end_of_file_code b'\xff\xff\xff\xff' 112 | 113 | ### Example: configure setup file 114 | 115 | The available setup options and values can be displayed with the the `--setup` 116 | option (or its abbreviation, `-s`). Example: 117 | 118 | $ python el4000.py setupel3.bin --setup 119 | header_magic b'\x00\x00\x00' 120 | unit_id 0 121 | hour_format 0 122 | date_format 0 123 | time_hour 0 124 | time_minute 0 125 | date_month 0 126 | date_day 0 127 | date_year 0 128 | currency 0 129 | tariff1 0.0 130 | tariff2 0.0 131 | 132 | To actually set values, specify one or more options to `--setup`. Definitions 133 | can be found in the file [defs.py](defs.py). Overview of options: 134 | 135 | - `unit_id`: ranges from 0 to 9. 136 | - `hour_format`: 1 for 12h format, 2 for 24h format. 137 | - `date_format`: 1 for mm/dd/yy, 2 for dd/mm/yy display. 138 | - `time_*` and `date_*`: set the initial clock. Note that `date_year` is in 139 | abbreviated form. Instead of `2014`, use `14`. 140 | - `currency`: 1 for `£`, 2 for Sfr, 4 for `$` and 8 for `€` 141 | - `tariff`, `tariff2`: ranges from 0.000 to 9.999. 142 | 143 | To modify (or create) the `setupel3.bin` file for a 24h clock, dd/mm/yy date 144 | format and euros, use: 145 | 146 | $ ./el4000.py setupel3.bin -s hour_format=2 date_format=2 currency=8 147 | Changing hour_format from 0 to 2 148 | Changing date_format from 0 to 2 149 | Changing currency from 0 to 8 150 | WARNING:Format:Invalid value 0 for name date_month 151 | WARNING:Format:Invalid value 0 for name date_day 152 | Setup file: setupel3.bin 153 | header_magic b'\xb8\xad\xf2' 154 | unit_id 0 155 | hour_format 2 156 | date_format 2 157 | time_hour 0 158 | time_minute 0 159 | date_month 0 160 | date_day 0 161 | date_year 0 162 | currency 8 163 | tariff1 0.0 164 | tariff2 0.0 165 | 166 | ## Contact 167 | 168 | If you have issues, questions, ideas or suggestions, feel free to contact me at 169 | peter@lekensteyn.nl or open a ticket at https://github.com/Lekensteyn/el4000/. 170 | Pull requests are also welcome. 171 | 172 | ## Copyright 173 | 174 | Copyright (C) 2014 Peter Wu 175 | 176 | Energy Logger 4000 utility is licensed under the MIT license. See the LICENSE 177 | file for more details. 178 | 179 | ## Links 180 | 181 | - References for EL3500: http://wiki.td-er.nl/index.php?title=Energy_Logger_3500 182 | - Energy Logger 4000 User manual (German, English and Dutch): 183 | http://www.produktinfo.conrad.com/datenblaetter/125000-149999/125444-an-01-ml-VOLTCRAFT_ENERGY_LOGGER_4000EKM_de_en_nl.pdf 184 | - File format documentation: 185 | http://www2.produktinfo.conrad.com/datenblaetter/125000-149999/125323-da-01-en-Datenprotokoll_SD_card_file_Formatv1_2.pdf 186 | 187 | [conrad]: http://www.conrad.com/ce/en/product/125444/VOLTCRAFT-ENERGY-LOGGER-4000-4320-hrs 188 | [python]: https://www.python.org/download/ 189 | -------------------------------------------------------------------------------- /el4000.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Utility to interpret files from the Voltcraft Energy Logger 4000 (as sold by 3 | # Conrad). 4 | # 5 | # Copyright (C) 2014 Peter Wu 6 | 7 | import os, sys 8 | from argparse import ArgumentParser 9 | import datetime 10 | import logging 11 | 12 | from defs import info, data_hdr, data, setup, SETUP_MAGIC, STARTCODE 13 | import printers 14 | 15 | _logger = logging.getLogger(__name__) 16 | 17 | def process_setup(filename, printer, setup_args): 18 | # Original setup template, default to empty 19 | setup_old = None 20 | 21 | try: 22 | size = os.path.getsize(filename) 23 | if size != 0 and size != setup.size(): 24 | raise RuntimeError(('Setup file {0} must non-existent, empty or of ' + 25 | 'size {1}, but found {2}').format(filename, setup.size(), size)) 26 | if size == setup.size(): 27 | with open(filename, 'rb') as f: 28 | setup_old = f.read(size) 29 | if len(setup_old) != setup.size(): 30 | _logger.warn('Unable to read setup file') 31 | except os.error: 32 | # File does not exist? 33 | pass 34 | # If setup file is not initialized, use an empty one 35 | if not setup_old or len(setup_old) != setup.size(): 36 | setup_old = setup.size() * b'\x00' 37 | 38 | # Unpack old file, ignoring any input errors 39 | old_t = setup.unpack(setup_old, validate=False) 40 | 41 | if not setup_args: 42 | # No setup parameters? Just show current values then. 43 | printers.print_namedtuple(old_t, setup) 44 | else: 45 | # Parameters were given. Create mutable dict from old tuples such that 46 | # the fields can be modified. 47 | t = dict([(k, v) for k, v in zip(old_t._fields, old_t)]) 48 | for arg in setup_args: 49 | if not '=' in arg: 50 | _logger.error('Option %s is missing value, skipping', arg) 51 | continue 52 | 53 | name, val = arg.split('=', 1) 54 | if not name in setup.names: 55 | _logger.error('Invalid setup key: {0}'.format(name)) 56 | else: 57 | print('Changing {0} from {1} to {2}'.format(name, t[name], val)) 58 | t[name] = float(val) 59 | 60 | # Build new file contents 61 | setup_new = setup.pack(t) 62 | new_t = setup.unpack(setup_new, validate=False) 63 | 64 | print('Setup file: ', filename) 65 | printers.print_namedtuple(new_t, setup) 66 | 67 | # If there are changes, write them away 68 | if setup_new != setup_old: 69 | _logger.info('Writing new file') 70 | with open(filename, 'wb') as f: 71 | f.write(setup_new) 72 | else: 73 | _logger.info('No changes, not writing file') 74 | 75 | def process_file(filename, printer, dt, data_only): 76 | with open(filename, 'rb') as f: 77 | size = os.fstat(f.fileno()).st_size 78 | if size == info.size(): 79 | # Info files 80 | t = info.parse_from_file(f) 81 | # Initialize time from info file 82 | dt[0] = datetime.datetime(2000 + t.init_date_year, 83 | t.init_date_month, t.init_date_day, 84 | t.init_time_hour, t.init_time_minute) 85 | if not data_only: 86 | printer.print_info(t) 87 | else: 88 | # Data files. 89 | 90 | # First, test whether this file is not a setup file 91 | buf = f.read(len(SETUP_MAGIC)) 92 | if buf == SETUP_MAGIC: 93 | _logger.warn('Setup file is ignored. Use --setup option instead') 94 | return 95 | 96 | eof = 4 * b'\xff' 97 | while True: 98 | if len(buf) < len(eof): 99 | buf += f.read(len(eof) - len(buf)) 100 | if not buf: 101 | break 102 | elif buf == eof[0:len(buf)]: 103 | # End of file code (or short read at the end) 104 | #sys.stdout.flush() 105 | #_logger.info('EOF detected, skipping') 106 | #continue 107 | break 108 | 109 | if buf[0:len(STARTCODE)] == STARTCODE: 110 | # Not data, but header before data 111 | buf += f.read(data_hdr.size() - len(buf)) 112 | t = data_hdr.unpack(buf) 113 | # New time reference! 114 | dt[0] = datetime.datetime(2000 + t.record_year, 115 | t.record_month, t.record_day, 116 | t.record_hour, t.record_minute) 117 | printer.print_data_header(t) 118 | else: 119 | buf += f.read(data.size() - len(buf)) 120 | t = data.unpack(buf) 121 | # For time reference 122 | date_str = dt[0].strftime('%Y-%m-%d %H:%M') 123 | printer.print_data(t, date=date_str) 124 | # Assume that this is called for every minute 125 | dt[0] += datetime.timedelta(minutes=1) 126 | 127 | # Clear buffer since it is processed 128 | buf = b'' 129 | 130 | 131 | verbosities = [ 132 | logging.CRITICAL, 133 | logging.ERROR, 134 | logging.WARNING, # Default 135 | logging.INFO, 136 | logging.DEBUG 137 | ] 138 | 139 | available_printers = { 140 | 'base': printers.BasePrinter, 141 | 'raw': printers.RawPrinter, 142 | 'csv': printers.CSVPrinter, 143 | 'watt': printers.EffectivePowerPrinter, 144 | 'va': printers.ApparentPowerPrinter 145 | } 146 | 147 | parser = ArgumentParser(description='Energy Logger 4000 utility') 148 | parser.add_argument('-p', '--printer', choices=available_printers.keys(), 149 | default='base', 150 | help="Output formatter (default '%(default)s')") 151 | parser.add_argument('-d', '--delimiter', default=',', 152 | help="Output delimiter for CSV output (default '%(default)s')") 153 | parser.add_argument('-v', '--verbose', action='count', 154 | default=verbosities.index(logging.WARNING), 155 | help='Increase logging level (twice for extra verbose)') 156 | parser.add_argument('-s', '--setup', metavar='key=value', nargs='*', 157 | help='Process a setupel3.bin file. Optional parameters \ 158 | can be given to set a field (-s unit_id=1 for example). \ 159 | If no parameters are given, the current values are printed') 160 | parser.add_argument('-o', '--data-only', action='store_true', 161 | help='Use info files only for updating the initial \ 162 | timestamp for data files, do not print their contents') 163 | parser.add_argument('files', metavar='binfile', nargs='+', 164 | help='info or data files (.bin) from SD card. If --setup \ 165 | is given, then this is the output file (and input for \ 166 | defaults). The order of files are significant when a \ 167 | timestamp is involved') 168 | 169 | if __name__ == '__main__': 170 | args = parser.parse_args() 171 | 172 | # Set log level on root 173 | args.verbose = min(args.verbose, len(verbosities) - 1) 174 | logging.basicConfig(level=verbosities[args.verbose]) 175 | 176 | myprinter = available_printers[args.printer] 177 | files_count = len(args.files) 178 | if args.setup is not None: 179 | if files_count != 1: 180 | _logger.error('Only one file can be specified for set-up') 181 | sys.exit(1) 182 | 183 | # Unknown date and time, initialize with something low. 184 | dt = [datetime.datetime(1970, 1, 1)] 185 | 186 | for filename in args.files: 187 | try: 188 | printer = myprinter(filename, separator=args.delimiter) 189 | except TypeError: 190 | printer = myprinter(filename) 191 | # Treat setup specially, it acts as input and output file 192 | if args.setup is not None: 193 | process_setup(args.files[0], myprinter, args.setup) 194 | else: 195 | # Display current filename for multiple files 196 | if files_count > 1 and not args.data_only: 197 | print('# ' + filename) 198 | 199 | process_file(filename, printer, dt, args.data_only) 200 | --------------------------------------------------------------------------------