#!/usr/bin/env python #!/usr/bin/env python # -*- coding: utf-8 -*- VER = '1.5.0013' """ 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 install json pycurl urllib2 configargparse Instructions: Execute command with option -d to retrieve config data from a host or use -f to read out a configuration file saved using Tasmota Web-UI For help execute command with argument -h Usage: decode-config.py [-h] [-f ] [-d ] [-u ] [-p ] [--json-indent ] [--json-compact] [--sort] [--unsort] [--raw-values] [--no-raw-values] [--raw-keys] [--no-raw-keys] [--hide-pw] [--unhide-pw] [-o ] [--output-file-format ] [-c ] [--exit-on-error-only] [-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 (default: None) --exit-on-error-only exit on error only (default: exit on ERROR and WARNING). Not recommended, used by your own responsibility! source: -f , --file file to retrieve Tasmota configuration from (default: None)' -d , --device hostname or IP address to retrieve Tasmota configuration from (default: None) -u , --username host HTTP access username (default: admin) -p , --password host HTTP access password (default: None) config: --json-indent pretty-printed JSON output using indent level (default: 'None'). Use values greater equal 0 to indent or -1 to disabled indent. --json-compact compact JSON output by eliminate whitespace --sort sort json keywords (default) --unsort do not sort json keywords --raw-values, --raw output raw values --no-raw-values output human readable values (default) --raw-keys output bitfield raw keys (default) --no-raw-keys do not output bitfield raw keys --hide-pw hide passwords (default) --unhide-pw unhide passwords -o , --output-file file to store configuration to (default: None). Replacements: @v=Tasmota version, @f=friendly name --output-file-format output format ('json' or 'binary', default: 'json') info: -V, --version show program's version number and exit Either argument -d or -f must be given. Returns: 0: successful 1: file not found 2: configuration version not supported 3: data size mismatch 4: data CRC error 5: configuration file read error 6: argument error 9: python module is missing 4xx, 5xx: HTTP error """ import os.path import io import sys def ModuleImportError(module): er = str(module) print("{}. Try 'pip install {}' to install it".format(er,er.split(' ')[len(er.split(' '))-1]) ) sys.exit(9) try: import struct import re import math from datetime import datetime import json import configargparse import pycurl import urllib2 except ImportError, e: ModuleImportError(e) PROG='{} v{} by Norbert Richter'.format(os.path.basename(sys.argv[0]),VER) CONFIG_FILE_XOR = 0x5A BINARYFILE_MAGIC = 0x63576223 args = {} DEFAULTS = { 'DEFAULT': { 'configfile': None, 'exitonwarning':True, }, 'source': { 'device': None, 'username': 'admin', 'password': None, 'tasmotafile': None, }, 'config': { 'jsonindent': None, 'jsoncompact': False, 'sort': True, 'rawvalues': False, 'rawkeys': True, 'hidepw': True, 'outputfile': None, 'outputfileformat': 'json', }, } exitcode = 0 """ 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. It is either a string or a tuple containing a string and a sub-Settings dictionary. 'xxx': A string is used to interpret the data at The string defines the format interpretion as described in 'struct module format string', see https://docs.python.org/2.7/library/struct.html#format-strings In addition to this format string there is as special meaning of a dot '.' - this means a bit with an optional prefix length. If no prefix is given, 1 is assumed. {}: A dictionary describes itself a 'Settings' dictonary (recursive) baseaddr The address (starting from 0) within config data. For bit fields must be a tuple. n: Defines a simple address within config data. must be a positive integer. (n, b, s): A tuple defines a bit field: is the address within config data (integer) how many bits are used (positive integer) bit shift (integer) positive shift the result right bits negative shift the result left bits 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 convert (optional) Define an output/conversion methode, can be a simple string or a previously defined function name. 'xxx?': a string will be evaluate as is replacing all '?' chars with the current value. This can also be contain pyhton code. func: a function defines the name of a formating function """ # config data conversion function and helper def int2ip(value): return '{:d}.{:d}.{:d}.{:d}'.format(value & 0xff, value>>8 & 0xff, value>>16 & 0xff, value>>24 & 0xff) def password(value): if args.hidepw: return '********' return value Setting_5_10_0 = { 'cfg_holder': ('>24) & 0xff) minor = ((ver>>16) & 0xff) release = ((ver>> 8) & 0xff) subrelease = (ver & 0xff) if major>=6: if subrelease>0: subreleasestr = str(subrelease) else: subreleasestr = '' else: if subrelease>0: subreleasestr = str(chr(subrelease+ord('a')-1)) else: subreleasestr = '' v = "{:d}.{:d}.{:d}{}{}".format( major, minor, release, '.' if (major>=6 and subreleasestr!='') else '', subreleasestr) filename = filename.replace('@v', v) if 'friendlyname' in configuration: filename = filename.replace('@f', configuration['friendlyname'][0] ) return filename def GetSettingsCrc(dobj): """ Return binary config data calclulated crc @param dobj: decrypted binary config data @return: 2 byte unsigned integer crc value """ crc = 0 for i in range(0, len(dobj)): if not i in [14,15]: # Skip crc crc += ord(dobj[i]) * (i+1) return crc & 0xffff def GetFieldFormat(fielddef): """ Return the format item of field definition @param fielddef: field format - see "Settings dictionary" above @return: from fielddef[0] """ return fielddef[0] def GetFieldBaseAddr(fielddef): """ Return the format item of field definition @param fielddef: field format - see "Settings dictionary" above @return: ,, from fielddef[1] """ baseaddr = fielddef[1] if isinstance(baseaddr, tuple): return baseaddr[0], baseaddr[1], baseaddr[2] return baseaddr, 0, 0 def MakeFieldBaseAddr(baseaddr, bitlen, bitshift): """ Return a based on given arguments @param baseaddr: baseaddr from Settings definition @param bitlen: 0 or bitlen @param bitshift: 0 or bitshift @return: (,,) if bitlen != 0 baseaddr if bitlen == 0 """ if bitlen!=0: return (baseaddr, bitlen, bitshift) return baseaddr def ConvertFieldValue(value, fielddef, raw=False): """ Convert field value based on field desc @param value: original value read from binary data @param fielddef field definition - see "Settings dictionary" above @param raw return raw values (True) or converted values (False) @return: (un)converted value """ if not raw and len(fielddef)>3: convert = fielddef[3] if isinstance(convert,str): # evaluate strings try: return eval(convert.replace('?','value')) except: return value elif callable(convert): # use as format function return convert(value) return value def GetFieldLength(fielddef): """ Return length of a field in bytes based on field format definition @param fielddef: field format - see "Settings dictionary" above @return: length of field in bytes """ length=0 format_ = GetFieldFormat(fielddef) # get datadef from field definition datadef = None if len(fielddef)>2: datadef = fielddef[2] if datadef is not None: # fielddef[2] contains a array or int # calc size recursive by sum of all elements # contains a integer list or an single integer value if (isinstance(datadef, list) \ and len(datadef)>0 \ and isinstance(datadef[0], int)) \ or isinstance(datadef, int): for i in range(0, datadef[0] if isinstance(datadef, list) else datadef ): # multidimensional array if isinstance(datadef, list) and len(datadef)>1: length += GetFieldLength( (fielddef[0], fielddef[1], fielddef[2][1:]) ) # single array else: length += GetFieldLength( (fielddef[0], fielddef[1], None) ) else: if isinstance(fielddef[0], dict): # -> iterate through format_ addr = -1 setting = fielddef[0] for name in setting: baseaddr, bitlen, bitshift = GetFieldBaseAddr(setting[name]) len_ = GetFieldLength(setting[name]) if addr != baseaddr: addr = baseaddr length += len_ else: if format_[-1:].lower() in ['b','c','?']: length=1 elif format_[-1:].lower() in ['h']: length=2 elif format_[-1:].lower() in ['i','l','f']: length=4 elif format_[-1:].lower() in ['q','d']: length=8 elif format_[-1:].lower() in ['s','p']: # s and p may have a prefix as length match = re.search("\s*(\d+)", format_) if match: length=int(match.group(0)) return length def GetField(dobj, fieldname, fielddef, raw=False, addroffset=0): """ Get field value from definition @param dobj: decrypted binary config data @param fieldname: name of the field @param fielddef: see Settings desc above @param raw return raw values (True) or converted values (False) @param addroffset use offset for baseaddr (used for recursive calls) @return: read field value """ result = None # get format from field definition format_ = GetFieldFormat(fielddef) # get baseaddr from field definition baseaddr, bitlen, bitshift = GetFieldBaseAddr(fielddef) # get datadef from field definition datadef = None if fielddef is not None and len(fielddef)>2: datadef = fielddef[2] if datadef is not None: result = [] # contains a integer list or an single integer value if (isinstance(datadef, list) \ and len(datadef)>0 \ and isinstance(datadef[0], int)) \ or isinstance(datadef, int): offset = 0 for i in range(0, datadef[0] if isinstance(datadef, list) else datadef): # multidimensional array if isinstance(datadef, list) and len(datadef)>1: if len(fielddef)<4: subfielddef = (fielddef[0], MakeFieldBaseAddr(baseaddr, bitlen, bitshift), datadef[1:]) else: subfielddef = (fielddef[0], MakeFieldBaseAddr(baseaddr, bitlen, bitshift), datadef[1:], fielddef[3]) # single array else: if len(fielddef)<4: subfielddef = (fielddef[0], MakeFieldBaseAddr(baseaddr, bitlen, bitshift), None) else: subfielddef = (fielddef[0], MakeFieldBaseAddr(baseaddr, bitlen, bitshift), None, fielddef[3]) length = GetFieldLength(subfielddef) if length != 0 and (fieldname != 'raw' or args.rawkeys): result.append(GetField(dobj, fieldname, subfielddef, raw=raw, addroffset=addroffset+offset)) offset += length else: # contains a dict if isinstance(fielddef[0], dict): # -> iterate through format_ setting = fielddef[0] config = {} for name in setting: if name != 'raw' or args.rawkeys: config[name] = GetField(dobj, name, setting[name], raw=raw, addroffset=addroffset) result = config else: # a simple value if GetFieldLength(fielddef) != 0: result = struct.unpack_from(format_, dobj, baseaddr+addroffset)[0] if not format_[-1:].lower() in ['s','p']: if bitshift>=0: result >>= bitshift else: result <<= abs(bitshift) if bitlen>0: result &= (1< 127 result = unicode(s, errors='ignore') result = ConvertFieldValue(result, fielddef, raw) 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 GetTemplateSetting(version): """ Search for template, settings and size to be used depending on given version number @param version: version number from read binary data to search for @return: template, settings to use, None if version is invalid """ # search setting definition template = None setting = None size = None for cfg in Settings: if version >= cfg[0]: template = cfg size = template[1] setting = template[2] break return template, size, setting def Decode(obj, raw=True): """ Decodes binary data stream @param obj: binary config data (decrypted) @param raw decode raw values (True) or converted values (False) @return: configuration dictionary """ # get header data version = GetField(obj, 'version', Setting_6_2_1['version'], raw=True) template, size, setting = GetTemplateSetting(version) # if we did not found a mathching setting if template is None: exit(2, "Tasmota configuration version 0x{:x} not supported".format(version) ) # check size if exists if 'cfg_size' in setting: cfg_size = GetField(obj, 'cfg_size', setting['cfg_size'], raw=True) # read size should be same as definied in template if cfg_size > size: # may be processed exit(3, "Number of bytes read does ot match - read {}, expected {} byte".format(cfg_size, template[1]), typ='WARNING', doexit=args.exitonwarning) elif cfg_size < size: # less number of bytes can not be processed exit(3, "Number of bytes read to small to process - read {}, expected {} byte".format(cfg_size, template[1]), typ='ERROR') # check crc if exists if 'cfg_crc' in setting: cfg_crc = GetField(obj, 'cfg_crc', setting['cfg_crc'], raw=True) else: cfg_crc = GetSettingsCrc(obj) if cfg_crc != GetSettingsCrc(obj): exit(4, 'Data CRC error, read 0x{:x} should be 0x{:x}'.format(cfg_crc, GetSettingsCrc(obj)), typ='WARNING', doexit=args.exitonwarning) # get config config = GetField(obj, None, (setting,None,None), raw=raw) # add header info timestamp = datetime.now() config['header'] = { 'timestamp': timestamp.strftime("%Y-%m-%d %H:%M:%S"), 'data': { 'crc': hex(GetSettingsCrc(obj)), 'size': len(obj), 'template_version': hex(template[0]), 'content': { 'crc': hex(cfg_crc), 'size': cfg_size, 'version': hex(version), }, }, 'scriptname': os.path.basename(__file__), 'scriptversion': VER, } return config if __name__ == "__main__": # program argument processing parser = configargparse.ArgumentParser(description='Decode configuration of Sonoff-Tasmota device.', epilog='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="hostname or IP address to retrieve Tasmota configuration from (default: {})".format(DEFAULTS['source']['device']) ) source.add_argument('-u', '--username', metavar='', dest='username', default=DEFAULTS['source']['username'], help="host HTTP access username (default: {})".format(DEFAULTS['source']['username'])) source.add_argument('-p', '--password', metavar='', dest='password', default=DEFAULTS['source']['password'], help="host HTTP access password (default: {})".format(DEFAULTS['source']['password'])) config = parser.add_argument_group('config') config.add_argument('--json-indent', metavar='', dest='jsonindent', type=int, default=DEFAULTS['config']['jsonindent'], help="pretty-printed JSON output using indent level (default: '{}'). Use values greater equal 0 to indent or -1 to disabled indent.".format(DEFAULTS['config']['jsonindent']) ) config.add_argument('--json-compact', dest='jsoncompact', action='store_true', default=DEFAULTS['config']['jsoncompact'], help="compact JSON output by eliminate whitespace{}".format(' (default)' if DEFAULTS['config']['jsoncompact'] else '') ) config.add_argument('--sort', dest='sort', action='store_true', default=DEFAULTS['config']['sort'], help="sort json keywords{}".format(' (default)' if DEFAULTS['config']['sort'] else '') ) config.add_argument('--unsort', dest='sort', action='store_false', default=DEFAULTS['config']['sort'], help="do not sort json keywords{}".format(' (default)' if not DEFAULTS['config']['sort'] else '') ) config.add_argument('--raw-values', '--raw', dest='rawvalues', action='store_true', default=DEFAULTS['config']['rawvalues'], help="output raw values{}".format(' (default)' if DEFAULTS['config']['rawvalues'] else '') ) config.add_argument('--no-raw-values', dest='rawvalues', action='store_false', default=DEFAULTS['config']['rawvalues'], help="output human readable values{}".format(' (default)' if not DEFAULTS['config']['rawvalues'] else '') ) config.add_argument('--raw-keys', dest='rawkeys', action='store_true', default=DEFAULTS['config']['rawkeys'], help="output bitfield raw keys{}".format(' (default)' if DEFAULTS['config']['rawkeys'] else '') ) config.add_argument('--no-raw-keys', dest='rawkeys', action='store_false', default=DEFAULTS['config']['rawkeys'], help="do not output bitfield raw keys{}".format(' (default)' if not DEFAULTS['config']['rawkeys'] else '') ) config.add_argument('--hide-pw', dest='hidepw', action='store_true', default=DEFAULTS['config']['hidepw'], help="hide passwords{}".format(' (default)' if DEFAULTS['config']['hidepw'] else '') ) config.add_argument('--unhide-pw', dest='hidepw', action='store_false', default=DEFAULTS['config']['hidepw'], help="unhide passwords{}".format(' (default)' if not DEFAULTS['config']['hidepw'] else '') ) config.add_argument('-o', '--output-file', metavar='', dest='outputfile', default=DEFAULTS['config']['outputfile'], help="file to store configuration to (default: {}). Replacements: @v=Tasmota version, @f=friendly name".format(DEFAULTS['config']['outputfile'])) config.add_argument('--output-file-format', metavar='', dest='outputfileformat', choices=['json', 'binary'], default=DEFAULTS['config']['outputfileformat'], help="output format ('json' or 'binary', default: '{}')".format(DEFAULTS['config']['outputfileformat']) ) 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 (default: {})".format(DEFAULTS['DEFAULT']['configfile']) ) parser.add_argument('--exit-on-error-only', dest='exitonwarning', action='store_false', default=DEFAULTS['DEFAULT']['exitonwarning'], help="exit on error only (default: {}). Not recommended, used by your own responsibility!".format('exit on ERROR and WARNING' if DEFAULTS['DEFAULT']['exitonwarning'] else 'exit on ERROR') ) info = parser.add_argument_group('info') info.add_argument('-V', '--version', action='version', version=PROG) args = parser.parse_args() # default no configuration available configobj = None # check source args if args.device is not None and args.tasmotafile is not None: exit(6, "Only one source allowed. Do not use -d and -f together") # read config direct from device via http if args.device is not None: 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() # read config from a file elif args.tasmotafile is not None: 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]) # no config source given else: parser.print_help() sys.exit(0) if configobj is not None and len(configobj)>0: cfg = DeEncrypt(configobj) configuration = Decode(cfg, args.rawvalues) # output to file if args.outputfile is not None: outputfilename = GetFilenameReplaced(args.outputfile, configuration) if args.outputfileformat == 'binary': outputfile = open(outputfilename, "wb") outputfile.write(struct.pack('