From ec421e614477115c99c67275bfafa5ecf33a8eb4 Mon Sep 17 00:00:00 2001 From: Theo Arends <11044339+arendst@users.noreply.github.com> Date: Tue, 25 Sep 2018 14:08:36 +0200 Subject: [PATCH] Released decode-config.py Released tools/decode-config.py by Norbert Richter to decode configuration data. --- sonoff/_changelog.ino | 1 + tools/decode-config.py | 1924 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1925 insertions(+) create mode 100644 tools/decode-config.py diff --git a/sonoff/_changelog.ino b/sonoff/_changelog.ino index 5aa6e9e13..333a524a6 100644 --- a/sonoff/_changelog.ino +++ b/sonoff/_changelog.ino @@ -1,6 +1,7 @@ /* 6.2.1.7 20180925 * Remove restart after ntpserver change and force NTP re-sync (#3890) * Release full Shelly2 support + * Released tools/decode-config.py by Norbert Richter to decode configuration data. See file for information * * 6.2.1.6 20180922 * Removed commands PowerCal, VoltageCal and CurrentCal as more functionality is provided by commands PowerSet, VoltageSet and CurrentSet diff --git a/tools/decode-config.py b/tools/decode-config.py new file mode 100644 index 000000000..299f152c4 --- /dev/null +++ b/tools/decode-config.py @@ -0,0 +1,1924 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" + decode-config.py - Decode configuration of Sonoff-Tasmota device + + Copyright (C) 2018 Norbert Richter + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Requirements: + - Python + - pip json pycurl urllib2 configargparse + +Instructions: + Execute command with option -d to retrieve config data from device or + use -f to read out a previously saved configuration file. + + For help execute command with argument -h + + +Usage: + decode-config.py [-h] [-f ] [-d ] + [-u ] [-p ] [--format ] + [--sort ] [--raw] [--unhide-pw] [-o ] + [-c ] [-V] + + Decode configuration of Sonoff-Tasmota device. Args that start with '--' (eg. + -f) can also be set in a config file (specified via -c). Config file syntax + allows: key=value, flag=true, stuff=[a,b,c] (for details, see syntax at + https://goo.gl/R74nmi). If an arg is specified in more than one place, then + commandline values override config file values which override defaults. + + optional arguments: + -h, --help show this help message and exit + -c , --config + Config file, can be used instead of command parameter + (defaults to None) + + source: + -f , --file + file to retrieve Tasmota configuration from (default: + None) + -d , --device + device to retrieve configuration from (default: None) + -u , --username + for -d usage: http access username (default: admin) + -p , --password + for -d usage: http access password (default: None) + + output: + --format output format ("json" or "text", default: "json") + --sort sort result - can be "none" or "name" (default: + "name") + --raw output raw values (default: processed) + --unhide-pw unhide passwords (default: hide) + -o , --output-file + file to store decrypted raw binary configuration to + (default: None) + + info: + -V, --version show program's version number and exit + + Note: Either argument -d or -f must be given. + + +Examples: + Read configuration from hostname 'sonoff1' and output default json config + ./decode-config.py -d sonoff1 + + Read configuration from file 'Config__6.2.1.dmp' and output default json config + ./decode-config.py -f Config__6.2.1.dmp + + Read configuration from hostname 'sonoff1' using web login data + ./decode-config.py -d sonoff1 -u admin -p xxxx + + Read configuration from hostname 'sonoff1' using web login data and unhide passwords + ./decode-config.py -d sonoff1 -u admin -p xxxx --unhide-pw + + Read configuration from hostname 'sonoff1' using web login data, unhide passwords + and sort key names + ./decode-config.py -d sonoff1 -u admin -p xxxx --unhide-pw --sort name +""" + +import os.path +import io +import sys +import configargparse +import collections +import struct +import re +import json +try: + import pycurl +except ImportError: + print("module not found. Try 'pip pycurl' to install it") + sys.exit(9) +try: + import urllib2 +except ImportError: + print("module not found. Try 'pip urllib2' to install it") + sys.exit(9) + + +VER = '1.5.0008' +PROG='{} v{} by Norbert Richter'.format(os.path.basename(sys.argv[0]),VER) + +CONFIG_FILE_XOR = 0x5A + + +args = {} +DEFAULTS = { + 'DEFAULT': + { + 'configfile': None, + }, + 'source': + { + 'device': None, + 'username': 'admin', + 'password': None, + 'tasmotafile': None, + }, + 'output': + { + 'format': 'json', + 'sort': 'name', + 'raw': False, + 'unhide-pw': False, + 'outputfile': None, + }, +} + + +""" +Settings dictionary describes the config file fields definition: + + Each setting name has a tuple containing the following items: + + (format, baseaddr, datadef, ) + + where + + format + Define the data interpretation. + For details see struct module format string + https://docs.python.org/2.7/library/struct.html#format-strings + + baseaddr + The address (starting from 0) within config data + + datadef + Define the field interpretation different from simple + standard types (like char, byte, int) e. g. lists or bit fields + Can be None, a single integer, a list or a dictionary + None: + None must be given if the field contains a simple value + desrcibed by the prefix + n: + Same as [n] below + [n]: + Defines a one-dimensional array of size + [n, n <,n...>] + Defines a multi-dimensional array + [{} <,{}...] + Defines a bit struct. The items are simply dict + {'bitname', bitlen}, the dict order is important. + + convert (optional) + Define an output/conversion methode, can be a simple string + or a previously defined function name. + 'xxx': + a string defines a format specification of the string + formatter, see + https://docs.python.org/2.7/library/string.html#format-string-syntax + func: + a function defines the name of a formating function + +""" +# config data conversion function and helper +def baudrate(value): + return value * 1200 + +def int2ip(value): + return '{:d}.{:d}.{:d}.{:d}'.format(value & 0xff, value>>8 & 0xff, value>>16 & 0xff, value>>24 & 0xff) + +def int2geo(value): + return float(value) / 1000000 + +def password(value): + if args.unhidepw: + return value + return '********' + +def fingerprintstr(value): + s = list(value) + result = '' + for c in s: + if c in '0123456789abcdefABCDEF': + result += c + return result + + +Setting_6_2_1 = { + 'cfg_holder': ('0 and isinstance(fielddef[2][0], int)) or isinstance(fielddef[2], int): + for i in range(0, fielddef[2][0] if isinstance(fielddef[2], list) else fielddef[2] ): + # multidimensional array + if isinstance(fielddef[2], list) and len(fielddef[2])>1: + length += GetFieldLength( (fielddef[0], fielddef[1], fielddef[2][1:]) ) + else: + length += GetFieldLength( (fielddef[0], fielddef[1], None) ) + else: + if fielddef[0][-1:].lower() in ['b','c','?']: + length=1 + elif fielddef[0][-1:].lower() in ['h']: + length=2 + elif fielddef[0][-1:].lower() in ['i','l','f']: + length=4 + elif fielddef[0][-1:].lower() in ['q','d']: + length=8 + elif fielddef[0][-1:].lower() in ['s','p']: + # s and p needs prefix as length + match = re.search("\s*(\d+)", fielddef[0]) + if match: + length=int(match.group(0)) + + # it's a single value + return length + +def ConvertFieldValue(value, fielddef): + """ + Convert field value based on field desc + + @param value: + original value read from binary data + @param fielddef + field definition (contains possible conversion defiinition) + + @return: (und)converted value + """ + if not args.raw and len(fielddef)>3: + if isinstance(fielddef[3],str): # use a format string + return fielddef[3].format(value) + elif callable(fielddef[3]): # use a format function + return fielddef[3](value) + return value + + +def GetField(dobj, fieldname, fielddef): + """ + Get field value from definition + + @param dobj: + uncrypted binary config data + @param fieldname: + name of the field + @param fielddef: + see Settings desc above + + @return: read field value + """ + + result = None + + if fielddef[2] is not None: + result = [] + + # tuple 2 contains a list with integer or an integer value + if (isinstance(fielddef[2], list) and len(fielddef[2])>0 and isinstance(fielddef[2][0], int)) or isinstance(fielddef[2], int): + addr = fielddef[1] + for i in range(0, fielddef[2][0] if isinstance(fielddef[2], list) else fielddef[2] ): + # multidimensional array + if isinstance(fielddef[2], list) and len(fielddef[2])>1: + subfielddef = (fielddef[0], addr, fielddef[2][1:], None if len(fielddef)<4 else fielddef[3]) + else: # single array + subfielddef = (fielddef[0], addr, None, None if len(fielddef)<4 else fielddef[3]) + length = GetFieldLength(subfielddef) + if length != 0: + result.append(GetField(dobj, fieldname, subfielddef)) + addr += length + # tuple 2 contains a list with dict + elif isinstance(fielddef[2], list) and len(fielddef[2])>0 and isinstance(fielddef[2][0], dict): + d = {} + value = struct.unpack_from(fielddef[0], dobj, fielddef[1])[0] + d['base'] = ConvertFieldValue(value, fielddef); + union = fielddef[2] + i = 0 + for l in union: + for name,bits in l.items(): + bitval = (value & ( ((1<> i + d[name] = bitval + i += bits + result = d + else: + # it's a single value + if GetFieldLength(fielddef) != 0: + result = struct.unpack_from(fielddef[0], dobj, fielddef[1])[0] + if fielddef[0][-1:].lower() in ['s','p']: + if ord(result[:1])==0x00 or ord(result[:1])==0xff: + result = '' + s = str(result).split('\0')[0] + result = s #unicode(s, errors='replace') + result = ConvertFieldValue(result, fielddef) + + return result + + +def DeEncrypt(obj): + """ + Decrpt/Encrypt binary config data + + @param obj: + binary config data + + @return: decrypted configuration (if obj contains encrypted data) + encrypted configuration (if obj contains decrypted data) + """ + dobj = obj[0:2] + for i in range(2, len(obj)): + dobj += chr( (ord(obj[i]) ^ (CONFIG_FILE_XOR +i)) & 0xff ) + return dobj + + +def Decode(obj): + """ + Decodes (already decrypted) binary data stream + + @param obj: + binary config data + """ + # get header data + cfg_size = GetField(obj, 'cfg_size', Setting_6_2_1['cfg_size']) + version = GetField(obj, 'version', Setting_6_2_1['version']) + + # search setting definition + setting = None + for cfg in Settings: + if version >= cfg[0] and cfg_size == cfg[1]: + template = cfg + break + + setting = template[2] + # if we did not found a mathching setting + if setting is None: + exit(2, "Can't handle Tasmota configuration data for version 0x{:x} with {} bytes".format(version, cfg_size) ) + + if GetField(obj, 'cfg_crc', setting['cfg_crc']) != GetSettingsCrc(obj): + exit(3, 'Data crc error' ) + + config = {} + config['version_template'] = '0x{:x}'.format(template[0]) + for name in setting: + config[name] = GetField(obj, name, setting[name]) + + if args.sort == 'name': + config = collections.OrderedDict(sorted(config.items())) + + if args.format == 'json': + print json.dumps(config, sort_keys=args.sort=='name') + else: + for key,value in config.items(): + print '{} = {}'.format(key, repr(value)) + + + +if __name__ == "__main__": + parser = configargparse.ArgumentParser(description='Decode configuration of Sonoff-Tasmota device.', + epilog='Note: Either argument -d or -f must be given.') + + source = parser.add_argument_group('source') + source.add_argument('-f', '--file', + metavar='', + dest='tasmotafile', + default=DEFAULTS['source']['tasmotafile'], + help='file to retrieve Tasmota configuration from (default: {})'.format(DEFAULTS['source']['tasmotafile'])) + source.add_argument('-d', '--device', + metavar='', + dest='device', + default=DEFAULTS['source']['device'], + help='device to retrieve configuration from (default: {})'.format(DEFAULTS['source']['device']) ) + source.add_argument('-u', '--username', + metavar='', + dest='username', + default=DEFAULTS['source']['username'], + help='for -d usage: http access username (default: {})'.format(DEFAULTS['source']['username'])) + source.add_argument('-p', '--password', + metavar='', + dest='password', + default=DEFAULTS['source']['password'], + help='for -d usage: http access password (default: {})'.format(DEFAULTS['source']['password'])) + + output = parser.add_argument_group('output') + output.add_argument('--format', + metavar='', + dest='format', + choices=['json', 'text'], + default=DEFAULTS['output']['format'], + help='output format ("json" or "text", default: "{}")'.format(DEFAULTS['output']['format']) ) + output.add_argument('--sort', + metavar='', + dest='sort', + choices=['none', 'name'], + default=DEFAULTS['output']['sort'], + help='sort result - can be "none" or "name" (default: "{}")'.format(DEFAULTS['output']['sort']) ) + output.add_argument('--raw', + dest='raw', + action='store_true', + default=DEFAULTS['output']['raw'], + help='output raw values (default: {})'.format('raw' if DEFAULTS['output']['raw'] else 'processed') ) + output.add_argument('--unhide-pw', + dest='unhidepw', + action='store_true', + default=DEFAULTS['output']['unhide-pw'], + help='unhide passwords (default: {})'.format('unhide' if DEFAULTS['output']['unhide-pw'] else 'hide') ) + output.add_argument('-o', '--output-file', + metavar='', + dest='outputfile', + default=DEFAULTS['output']['outputfile'], + help='file to store decrypted raw binary configuration to (default: {})'.format(DEFAULTS['output']['outputfile'])) + + parser.add_argument('-c', '--config', + metavar='', + dest='configfile', + default=DEFAULTS['DEFAULT']['configfile'], + is_config_file=True, + help='Config file, can be used instead of command parameter (defaults to {})'.format(DEFAULTS['DEFAULT']['configfile']) ) + + info = parser.add_argument_group('info') + info.add_argument('-V', '--version', action='version', version=PROG) + + args = parser.parse_args() + + configobj = None + + if args.device is not None: + + # read config direct from device via http + buffer = io.BytesIO() + url = str("http://{}/dl".format(args.device)) + c = pycurl.Curl() + c.setopt(c.URL, url) + c.setopt(c.VERBOSE, 0) + if args.username is not None and args.password is not None: + c.setopt(c.HTTPAUTH, c.HTTPAUTH_BASIC) + c.setopt(c.USERPWD, args.username + ':' + args.password) + c.setopt(c.WRITEDATA, buffer) + try: + c.perform() + except Exception, e: + exit(e[0], e[1]) + response = c.getinfo(c.RESPONSE_CODE) + c.close() + if response>=400: + exit(response, 'HTTP returns {}'.format(response) ) + + configobj = buffer.getvalue() + + elif args.tasmotafile is not None: + # read config from a file + + if not os.path.isfile(args.tasmotafile): # check file exists + exit(1, "file '{}' not found".format(args.tasmotafile)) + try: + tasmotafile = open(args.tasmotafile, "rb") + configobj = tasmotafile.read() + tasmotafile.close() + except Exception, e: + exit(e[0], e[1]) + + else: + parser.print_help() + sys.exit(0) + + if configobj is not None and len(configobj)>0: + cfg = DeEncrypt(configobj) + + if args.outputfile is not None: + outputfile = open(args.outputfile, "wb") + outputfile.write(cfg) + outputfile.close() + + Decode(cfg) + + else: + exit(4, "Could not read configuration data from {} '{}'".format('device' if args.device is not None else 'file', args.device if args.device is not None else args.tasmotafile) ) \ No newline at end of file