#!/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) )