#!/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 install 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 ] [--json-indent ] [--json-compact] [--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 (default: None) 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) output: --format output format ("json" or "text", default: "json") --json-indent pretty-printed JSON output using indent level (default: "None") --json-compact compact JSON output by eliminate whitespace (default: "not compact") --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 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.0009' 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', 'jsonindent': None, 'jsoncompact': False, '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, raw=False): """ Convert field value based on field desc @param value: original value read from binary data @param fielddef field definition (contains possible conversion defiinition) @param raw return raw values (True) or converted values (False) @return: (un)converted value """ if not 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, raw=False): """ Get field value from definition @param dobj: uncrypted 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) @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, raw)) 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, raw); 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 = unicode(s, errors='replace') 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 Decode(obj): """ Decodes binary data stream @param obj: binary config data (decrypted) """ # get header data version = GetField(obj, 'version', Setting_6_2_1['version'], raw=True) # search setting definition template = None for cfg in Settings: if version >= cfg[0]: template = cfg break # if we did not found a mathching setting if template is None: exit(2, "Can't handle Tasmota configuration data for version 0x{:x}".format(version) ) setting = template[2] # check size if exists if 'cfg_size' in setting: cfg_size = GetField(obj, 'cfg_size', setting['cfg_size'], raw=True) # if we did not found a mathching setting if cfg_size != template[1]: exit(2, "Data size does not match. Expected {} bytes, read {} bytes.".format(template[1], cfg_size) ) # 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(3, 'Data crc error' ) config = {} config['version_template'] = '0x{:x}'.format(template[0]) for name in setting: config[name] = GetField(obj, name, setting[name], args.raw) if args.sort == 'name': config = collections.OrderedDict(sorted(config.items())) if args.format == 'json': print json.dumps(config, sort_keys=args.sort=='name', indent=args.jsonindent, separators=(',', ':') if args.jsoncompact else (', ', ': ') ) 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='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'])) 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('--json-indent', metavar='', dest='jsonindent', type=int, default=DEFAULTS['output']['jsonindent'], help='pretty-printed JSON output using indent level (default: "{}")'.format(DEFAULTS['output']['jsonindent']) ) output.add_argument('--json-compact', dest='jsoncompact', action='store_true', default=DEFAULTS['output']['jsoncompact'], help='compact JSON output by eliminate whitespace (default: "{}")'.format('compact' if DEFAULTS['output']['jsoncompact'] else 'not compact') ) 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 (default: {})'.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) ) sys.exit(0)