diff --git a/tools/decode-config.html b/tools/decode-config.html new file mode 100644 index 000000000..dd3d43d01 --- /dev/null +++ b/tools/decode-config.html @@ -0,0 +1,230 @@ +

decode-config.py

+

decode-config.py backup and restore Sonoff-Tasmota configuration.

+

Comparing backup files created by decode-config.py and *.dmp files created by Tasmota "Backup/Restore Configuration":

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 decode-config.py
*.json file
Sonoff-Tasmota
*.dmp file
EncryptedNoYes
ReadableYesNo
Simply editableYesNo
Simply batch processingYesNo
+

decode-config.py handles Tasmota configurations for release version since 5.10.0 up to now.

+

Content

+ +

Prerequisite

+ +

File Types

+

decode-config.py can handle the following backup file types:

+

.dmp Format

+

Configuration data as used by Tasmota "Backup/Restore Configuration" web interface.
This format is binary and encrypted.

+

.json Format

+

Configuration data in JSON-format.
This format is decrypted, human readable and editable and can also be used for the --restore-file command.
This file will becreated by decode-config.py using --backup-file with --backup-type json parameter (default).

+

.bin Format

+

Configuration data in binary format.
This format is binary decryptet, editable (e.g. using a hex editor) and can also be used for --restore-file command.
It will be created by decode-config.py using --backup-file with --backup-type bin.
Note:
This file is 4 byte longer than an original .dmp file due to an prefix header at the beginning. The file data starting at address position 4 are containing the same as the struct SYSCFG from Tasmota settings.h in decrypted format.

+

File extensions

+

decode-config.py uses auto extension as default for backup filenames; you don't need to append extensions to your backup file, it will be selected based on --backup-type argument.
If you want using your own extension use the --no-extension argument.

+

Usage

+

After download don't forget to set exec flag under linux with chmod +x decode-config.py or call the program using python decode-config.py....

+

Basics

+

At least pass a source where you want to read the configuration data from using -f <filename> or -d <host>:

+

The source can be either

+ +

Example:

+
decode-config.py -d sonoff-4281
+

will output a human readable configuration in JSON-format:

+
{
+  "altitude": 112, 
+  "baudrate": 115200, 
+  "blinkcount": 10, 
+  "blinktime": 10, 
+...
+  "ws_width": [
+    1, 
+    3, 
+    5
+  ]
+}
+

Save backup file

+

To save the output as backup file --backup-file <filename>, you can use placeholder for Version, Friendlyname and Hostname:

+
decode-config.py -d sonoff-4281 --backup-file Config_@f_@v
+

If you have setup a WebPassword within Tasmota, use

+
decode-config.py -d sonoff-4281 -p <yourpassword> --backup-file Config_@f_@v
+

will create a file like Config_Sonoff_x.x.x.json. Because it is in JSON format, you can read and edit the file with any raw text editor.

+

Restore backup file

+

Reading back a saved (and possible changed) backup file use the --restore-file <filename> arg. This will read the (changed) configuration data from this file and send it back to the source device or filename.

+

To restore the previously save backup file Config_Sonoff_6.2.1.json to device sonoff-4281 use:

+
decode-config.py -d sonoff-4281 --restore-file Config_Sonoff_6.2.1.json
+

with password set by WebPassword:

+
decode-config.py -d sonoff-4281 -p <yourpassword> --restore-file Config_Sonoff_6.2.1.json
+

Configuration file

+

Each argument that start with -- (eg. --file) 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://pypi.org/project/ConfigArgParse).

+

If an argument is specified in more than one place, then commandline values override config file values which override defaults. This is usefull if you always use the same argument or a basic set of arguments.

+

The http authentication credentials --username and --password is predestinated to store it in a file instead using it on your command line as argument:

+

e.g. my.conf:

+
[source]
+username = admin
+password = myPaszxwo!z
+

To make a backup file from example above you can now pass the config file instead using the password on command line:

+
decode-config.py -d sonoff-4281 -c my.conf --backup-file Config_@f_@v
+

More program arguments

+

For better reading your porgram arguments each short written arg (minus sign -) has a corresponding readable long version (two minus signs --), eg. --device for -d or --file for -f (note: not even all -- arg has a corresponding - one).

+

A short list of possible program args is displayed using -h or --help.

+

For advanced help use -H or --full-help:

+
usage: decode-config.py [-f <filename>] [-d <host>] [-P <port>]
+                        [-u <username>] [-p <password>] [-i <filename>]
+                        [-o <filename>] [-F json|bin|dmp] [-E] [-e]
+                        [--json-indent <indent>] [--json-compact]
+                        [--json-hide-pw] [--json-unhide-pw] [-h] [-H] [-v]
+                        [-V] [-c <filename>] [--ignore-warnings]
+
+Backup/Restore Sonoff-Tasmota configuration data. 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:
+  -c, --config <filename>
+                        program config file - can be used to set default
+                        command args (default: None)
+  --ignore-warnings     do not exit on warnings. Not recommended, used by your
+                        own responsibility!
+
+Source:
+  Read/Write Tasmota configuration from/to
+
+  -f, --file, --tasmota-file <filename>
+                        file to retrieve/write Tasmota configuration from/to
+                        (default: None)'
+  -d, --device, --host <host>
+                        hostname or IP address to retrieve/send Tasmota
+                        configuration from/to (default: None)
+  -P, --port <port>     TCP/IP port number to use for the host connection
+                        (default: 80)
+  -u, --username <username>
+                        host HTTP access username (default: admin)
+  -p, --password <password>
+                        host HTTP access password (default: None)
+
+Backup/Restore:
+  Backup/Restore configuration file specification
+
+  -i, --restore-file <filename>
+                        file to restore configuration from (default: None).
+                        Replacements: @v=firmware version, @f=device friendly
+                        name, @h=device hostname
+  -o, --backup-file <filename>
+                        file to backup configuration to (default: None).
+                        Replacements: @v=firmware version, @f=device friendly
+                        name, @h=device hostname
+  -F, --backup-type json|bin|dmp
+                        backup filetype (default: 'json')
+  -E, --extension       append filetype extension for -i and -o filename
+                        (default)
+  -e, --no-extension    do not append filetype extension, use -i and -o
+                        filename as passed
+
+JSON:
+  JSON backup format specification
+
+  --json-indent <indent>
+                        pretty-printed JSON output using indent level
+                        (default: 'None'). -1 disables indent.
+  --json-compact        compact JSON output by eliminate whitespace
+  --json-hide-pw        hide passwords (default)
+  --json-unhide-pw      unhide passwords
+
+Info:
+  additional information
+
+  -h, --help            show usage help message and exit
+  -H, --full-help       show full help message and exit
+  -v, --verbose         produce more output about what the program does
+  -V, --version         show program's version number and exit
+
+Either argument -d <host> or -f <filename> must be given.
+

Examples

+

The most of the examples are for linux command line. Under Windows call the program using python decode-config.py ....

+

Config file

+

Note: The example contains .ini style sections [...]. Sections are always treated as comment and serves as clarity only. +For further details of config file syntax see https://pypi.org/project/ConfigArgParse.

+

my.conf

+
[Source]
+username = admin
+password = myPaszxwo!z
+
+[JSON]
+json-indent 2
+

Using Tasmota binary configuration files

+
    +
  1. Restore a Tasmota configuration file

    +

    decode-config.py -c my.conf -d sonoff --restore-file Config_Sonoff_6.2.1.dmp

    +
  2. +
  3. Backup device using Tasmota configuration compatible format

    +

    a) use file extension to choice the file format

    +

    decode-config.py -c my.conf -d sonoff --backup-file Config_@f_@v.dmp

    +

    b) use args to choice the file format

    +

    decode-config.py -c my.conf -d sonoff --backup-type dmp --backup-file Config_@f_@v

    +
  4. +
+

Use batch processing

+
for device in sonoff1 sonoff2 sonoff3; do ./decode-config.py -c my.conf -d $device -o Config_@f_@v
+

or under windows

+
for device in (sonoff1 sonoff2 sonoff3) do python decode-config.py -c my.conf -d %device -o Config_@f_@v
+

will produce JSON configuration files for host sonoff1, sonoff2 and sonoff3 using friendly name and Tasmota firmware version for backup filenames.

diff --git a/tools/decode-config.md b/tools/decode-config.md new file mode 100644 index 000000000..d1cb05bd8 --- /dev/null +++ b/tools/decode-config.md @@ -0,0 +1,253 @@ +# decode-config.py +_decode-config.py_ backup and restore Sonoff-Tasmota configuration. + +Comparing backup files created by *decode-config.py* and *.dmp files created by Tasmota "Backup/Restore Configuration": + +|   | decode-config.py
*.json file | Sonoff-Tasmota
*.dmp file | +|-------------------------|:-------------------------------:|:-----------------------------------:| +| Encrypted | No | Yes | +| Readable | Yes | No | +| Simply editable | Yes | No | +| Simply batch processing | Yes | No | + +_decode-config.py_ handles Tasmota configurations for release version since 5.10.0 up to now. + +# Content +* [Prerequisite](decode-config.md#prerequisite) +* [File Types](decode-config.md#file-types) + * [.dmp File Format](decode-config.md#-dmp-file-format) + * [.json File Format](decode-config.md#-json-file-format) + * [.bin File Format](decode-config.md#-bin-file-format) + * [File extensions](decode-config.md#file-extensions) +* [Usage](decode-config.md#usage) + * [Basics](decode-config.md#basics) + * [Save backup file](decode-config.md#save-backup-file) + * [Restore backup file](decode-config.md#restore-backup-file) + * [Configuration file](decode-config.md#configuration-file) + * [More program arguments](decode-config.md#more-program-arguments) + * [Examples](decode-config.md#examples) + * [Config file](decode-config.md#config-file) + * [Using Tasmota binary configuration files](decode-config.md#using-tasmota-binary-configuration-files) + * [Use batch processing](decode-config.md#use-batch-processing) + +## Prerequisite +* [Python](https://en.wikipedia.org/wiki/Python_(programming_language)) + This program is written in [Python](https://en.wikipedia.org/wiki/Python_(programming_language)) so you need to install a python environment (for details see [Python Setup and Usage](https://docs.python.org/2.7/using/index.html)) +* [Sonoff-Tasmota](https://github.com/arendst/Sonoff-Tasmota) [Firmware](https://github.com/arendst/Sonoff-Tasmota/releases) with enabled Web-Server + To backup or restore configurations from/to a Sonoff-Tasmota device you need a firmare with enabled web-server in admin mode (command [WebServer 2](https://github.com/arendst/Sonoff-Tasmota/wiki/Commands#wifi)). +
Only self compiled firmware may do not have a web-server sod if you use your own compiled firmware be aware to enable the web-server, otherwise you can only use the `--file` parameter as source. + +## File Types +_decode-config.py_ can handle the following backup file types: +### .dmp Format +Configuration data as used by Tasmota "Backup/Restore Configuration" web interface. +This format is binary and encrypted. +### .json Format +Configuration data in [JSON](http://www.json.org/)-format. +This format is decrypted, human readable and editable and can also be used for the `--restore-file` command. +This file will becreated by _decode-config.py_ using `--backup-file` with `--backup-type json` parameter (default). +### .bin Format +Configuration data in binary format. +This format is binary decryptet, editable (e.g. using a hex editor) and can also be used for `--restore-file` command. +It will be created by _decode-config.py_ using `--backup-file` with `--backup-type bin`. +Note: +This file is 4 byte longer than an original .dmp file due to an prefix header at the beginning. The file data starting at address position 4 are containing the same as the **struct SYSCFG** from Tasmota [settings.h](https://github.com/arendst/Sonoff-Tasmota/blob/master/sonoff/settings.h) in decrypted format. + +#### File extensions +_decode-config.py_ uses auto extension as default for backup filenames; you don't need to append extensions to your backup file, it will be selected based on `--backup-type` argument. +If you want using your own extension use the `--no-extension` argument. + +## Usage +After download don't forget to set exec flag under linux with `chmod +x decode-config.py` or call the program using `python decode-config.py...`. + +### Basics +At least pass a source where you want to read the configuration data from using `-f ` or `-d `: + +The source can be either +* a Tasmota device hostname or IP by passing it using the `-d ` arg +* or a previously stored Tasmota *.dmp` configuration file by passing the filename using `-f ` arg + +Example: + + decode-config.py -d sonoff-4281 + +will output a human readable configuration in [JSON](http://www.json.org/)-format: + + { + "altitude": 112, + "baudrate": 115200, + "blinkcount": 10, + "blinktime": 10, + ... + "ws_width": [ + 1, + 3, + 5 + ] + } + + +### Save backup file +To save the output as backup file `--backup-file `, you can use placeholder for Version, Friendlyname and Hostname: + + decode-config.py -d sonoff-4281 --backup-file Config_@f_@v + +If you have setup a WebPassword within Tasmota, use + + decode-config.py -d sonoff-4281 -p --backup-file Config_@f_@v + +will create a file like `Config_Sonoff_x.x.x.json`. Because it is in JSON format, you can read and edit the file with any raw text editor. + +### Restore backup file +Reading back a saved (and possible changed) backup file use the `--restore-file ` arg. This will read the (changed) configuration data from this file and send it back to the source device or filename. + +To restore the previously save backup file `Config_Sonoff_6.2.1.json` to device `sonoff-4281` use: + + decode-config.py -d sonoff-4281 --restore-file Config_Sonoff_6.2.1.json + +with password set by WebPassword: + + decode-config.py -d sonoff-4281 -p --restore-file Config_Sonoff_6.2.1.json + +### Configuration file +Each argument that start with `--` (eg. `--file`) 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://pypi.org/project/ConfigArgParse](https://pypi.org/project/ConfigArgParse/)). + +If an argument is specified in more than one place, then commandline values override config file values which override defaults. This is usefull if you always use the same argument or a basic set of arguments. + +The http authentication credentials `--username` and `--password` is predestinated to store it in a file instead using it on your command line as argument: + +e.g. my.conf: + + [source] + username = admin + password = myPaszxwo!z + +To make a backup file from example above you can now pass the config file instead using the password on command line: + + decode-config.py -d sonoff-4281 -c my.conf --backup-file Config_@f_@v + + + +### More program arguments +For better reading your porgram arguments each short written arg (minus sign `-`) has a corresponding readable long version (two minus signs `--`), eg. `--device` for `-d` or `--file` for `-f` (note: not even all `--` arg has a corresponding `-` one). + +A short list of possible program args is displayed using `-h` or `--help`. + +For advanced help use `-H` or `--full-help`: + + usage: decode-config.py [-f ] [-d ] [-P ] + [-u ] [-p ] [-i ] + [-o ] [-F json|bin|dmp] [-E] [-e] + [--json-indent ] [--json-compact] + [--json-hide-pw] [--json-unhide-pw] [-h] [-H] [-v] + [-V] [-c ] [--ignore-warnings] + + Backup/Restore Sonoff-Tasmota configuration data. 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: + -c, --config + program config file - can be used to set default + command args (default: None) + --ignore-warnings do not exit on warnings. Not recommended, used by your + own responsibility! + + Source: + Read/Write Tasmota configuration from/to + + -f, --file, --tasmota-file + file to retrieve/write Tasmota configuration from/to + (default: None)' + -d, --device, --host + hostname or IP address to retrieve/send Tasmota + configuration from/to (default: None) + -P, --port TCP/IP port number to use for the host connection + (default: 80) + -u, --username + host HTTP access username (default: admin) + -p, --password + host HTTP access password (default: None) + + Backup/Restore: + Backup/Restore configuration file specification + + -i, --restore-file + file to restore configuration from (default: None). + Replacements: @v=firmware version, @f=device friendly + name, @h=device hostname + -o, --backup-file + file to backup configuration to (default: None). + Replacements: @v=firmware version, @f=device friendly + name, @h=device hostname + -F, --backup-type json|bin|dmp + backup filetype (default: 'json') + -E, --extension append filetype extension for -i and -o filename + (default) + -e, --no-extension do not append filetype extension, use -i and -o + filename as passed + + JSON: + JSON backup format specification + + --json-indent + pretty-printed JSON output using indent level + (default: 'None'). -1 disables indent. + --json-compact compact JSON output by eliminate whitespace + --json-hide-pw hide passwords (default) + --json-unhide-pw unhide passwords + + Info: + additional information + + -h, --help show usage help message and exit + -H, --full-help show full help message and exit + -v, --verbose produce more output about what the program does + -V, --version show program's version number and exit + + Either argument -d or -f must be given. + + +### Examples +The most of the examples are for linux command line. Under Windows call the program using `python decode-config.py ...`. + +#### Config file +Note: The example contains .ini style sections `[...]`. Sections are always treated as comment and serves as clarity only. +For further details of config file syntax see [https://pypi.org/project/ConfigArgParse](https://pypi.org/project/ConfigArgParse/). + +*my.conf* + + [Source] + username = admin + password = myPaszxwo!z + + [JSON] + json-indent 2 + +#### Using Tasmota binary configuration files + +1. Restore a Tasmota configuration file + + `decode-config.py -c my.conf -d sonoff --restore-file Config_Sonoff_6.2.1.dmp` + +2. Backup device using Tasmota configuration compatible format + + a) use file extension to choice the file format + + `decode-config.py -c my.conf -d sonoff --backup-file Config_@f_@v.dmp` + + b) use args to choice the file format + + `decode-config.py -c my.conf -d sonoff --backup-type dmp --backup-file Config_@f_@v` + +#### Use batch processing + + for device in sonoff1 sonoff2 sonoff3; do ./decode-config.py -c my.conf -d $device -o Config_@f_@v + +or under windows + + for device in (sonoff1 sonoff2 sonoff3) do python decode-config.py -c my.conf -d %device -o Config_@f_@v + +will produce JSON configuration files for host sonoff1, sonoff2 and sonoff3 using friendly name and Tasmota firmware version for backup filenames. diff --git a/tools/decode-config.py b/tools/decode-config.py old mode 100644 new mode 100755 index ffa78c031..1eb991d11 --- a/tools/decode-config.py +++ b/tools/decode-config.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -#!/usr/bin/env python # -*- coding: utf-8 -*- -VER = '1.5.0013' +VER = '2.0.0000' """ - decode-config.py - Decode configuration of Sonoff-Tasmota device + decode-config.py - Backup/Restore Sonoff-Tasmota configuration data Copyright (C) 2018 Norbert Richter @@ -29,68 +28,83 @@ Requirements: 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 + or use -f to read a configuration file saved using Tasmota Web-UI + + For further information read 'decode-config.md' - For help execute command with argument -h + For help execute command with argument -h (or -H for advanced help) -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] +Usage: decode-config.py [-f ] [-d ] [-P ] + [-u ] [-p ] [-i ] + [-o ] [-F json|bin|dmp] [-E] [-e] + [--json-indent ] [--json-compact] + [--json-hide-pw] [--json-unhide-pw] [-h] [-H] [-v] + [-V] [-c ] [--ignore-warnings] - 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 + Backup/Restore Sonoff-Tasmota configuration data. 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! + -c, --config + program config file - can be used to set default + command args (default: None) + --ignore-warnings do not exit on warnings. 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 + Source: + Read/Write Tasmota configuration from/to + + -f, --file, --tasmota-file + file to retrieve/write Tasmota configuration from/to + (default: None)' + -d, --device, --host + hostname or IP address to retrieve/send Tasmota + configuration from/to (default: None) + -P, --port TCP/IP port number to use for the host connection + (default: 80) + -u, --username host HTTP access username (default: admin) - -p , --password + -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') + Backup/Restore: + Backup/Restore configuration file specification - info: + -i, --restore-file + file to restore configuration from (default: None). + Replacements: @v=firmware version, @f=device friendly + name, @h=device hostname + -o, --backup-file + file to backup configuration to (default: None). + Replacements: @v=firmware version, @f=device friendly + name, @h=device hostname + -F, --backup-type json|bin|dmp + backup filetype (default: 'json') + -E, --extension append filetype extension for -i and -o filename + (default) + -e, --no-extension do not append filetype extension, use -i and -o + filename as passed + + JSON: + JSON backup format specification + + --json-indent + pretty-printed JSON output using indent level + (default: 'None'). -1 disables indent. + --json-compact compact JSON output by eliminate whitespace + --json-hide-pw hide passwords (default) + --json-unhide-pw unhide passwords + + Info: + additional information + + -h, --help show usage help message and exit + -H, --full-help show full help message and exit + -v, --verbose produce more output about what the program does -V, --version show program's version number and exit Either argument -d or -f must be given. @@ -98,29 +112,54 @@ Usage: 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 + 1: restore skipped + 2: program argument error + 3: file not found + 4: data size mismatch + 5: data CRC error + 6: unsupported configuration version + 7: configuration file read error + 8: JSON file decoding error + 9: Restore file data error + 10: Device data download error + 11: Device data upload error + 20: python module missing + 21: Internal error + >21: python library exit code + 4xx, 5xx: HTTP errors """ +class ExitCode: + OK = 0 + RESTORE_SKIPPED = 1 + ARGUMENT_ERROR = 2 + FILE_NOT_FOUND = 3 + DATA_SIZE_MISMATCH = 4 + DATA_CRC_ERROR = 5 + UNSUPPORTED_VERSION = 6 + FILE_READ_ERROR = 7 + JSON_READ_ERROR = 8 + RESTORE_DATA_ERROR = 9 + DOWNLOAD_CONFIG_ERROR = 10 + UPLOAD_CONFIG_ERROR = 11 + MODULE_NOT_FOUND = 20 + INTERNAL_ERROR = 21 + import os.path import io -import sys +import sys, platform def ModuleImportError(module): er = str(module) - print("{}. Try 'pip install {}' to install it".format(er,er.split(' ')[len(er.split(' '))-1]) ) - sys.exit(9) + print >> sys.stderr, "{}. Try 'pip install {}' to install it".format(er,er.split(' ')[len(er.split(' '))-1]) + sys.exit(ExitCode.MODULE_NOT_FOUND) try: + from datetime import datetime import struct + import socket import re import math - from datetime import datetime + import inspect import json import configargparse import pycurl @@ -129,37 +168,43 @@ except ImportError, e: ModuleImportError(e) -PROG='{} v{} by Norbert Richter'.format(os.path.basename(sys.argv[0]),VER) +PROG='{} v{} by Norbert Richter '.format(os.path.basename(sys.argv[0]),VER) -CONFIG_FILE_XOR = 0x5A -BINARYFILE_MAGIC = 0x63576223 - -args = {} -DEFAULTS = { +CONFIG_FILE_XOR = 0x5A +BINARYFILE_MAGIC = 0x63576223 +STR_ENCODING = 'utf8' +DEFAULTS = { 'DEFAULT': { 'configfile': None, - 'exitonwarning':True, + 'ignorewarning':False, }, 'source': { 'device': None, + 'port': 80, 'username': 'admin', 'password': None, 'tasmotafile': None, }, - 'config': + 'backup': + { + 'restorefile': None, + 'backupfile': None, + 'backupfileformat': 'json', + 'extension': True, + }, + 'jsonformat': { 'jsonindent': None, 'jsoncompact': False, - 'sort': True, - 'rawvalues': False, - 'rawkeys': True, - 'hidepw': True, - 'outputfile': None, - 'outputfileformat': 'json', + 'jsonsort': True, + 'jsonrawvalues':False, + 'jsonrawkeys': False, + 'jsonhidepw': True, }, } +args = {} exitcode = 0 @@ -218,33 +263,47 @@ Settings dictionary describes the config file fields definition: [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. + converter (optional) + Conversion methode(s): ()|'xxx'|func + Read conversion is used if args.jsonrawvalues is False + Write conversion is used if jsonrawvalues from restore json + file is False or args.jsonrawvalues is False. + Converter is either a single methode 'xxx'|func or a tuple + Single methode will be used for reading conversion only: + 'xxx': + string will used for reading conversion and will be + evaluate as is, this can also contain python code. + Use '$' for current value. func: - a function defines the name of a formating function + name of a formating function that will be used for + reading conversion + None: + will read as definied in + (read, write): + a tuple with 2 objects. Each can be of the same type + as the single method above ('xxx'|func) or None. + read: + method will be used for read conversion + (unpack data from dmp object) + write: + method will be used for write conversion + (pack data to dmp object) + If write method is None indicates value is + readable only and will not be write """ -# 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 +def passwordread(value): + return "********" if args.jsonhidepw else value +def passwordwrite(value): + return None if value=="********" else value Setting_5_10_0 = { - 'cfg_holder': (' version number from read binary data to search for + + @return: + template sizes as list [] + """ + sizes = [] + for cfg in Settings: + sizes.append(cfg[1]) + # return unique sizes only (remove duplicates) + return list(set(sizes)) + + +def GetTemplateSetting(decode_cfg): + """ + Search for version, template, size and settings to be used depending on given binary config data + + @param decode_cfg: + binary config data (decrypted) + + @return: + version, template, size, settings to use; None if version is invalid + """ + try: + version = GetField(decode_cfg, 'version', Setting_6_2_1['version'], raw=True) + except: + return None,None,None,None + + # 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 version, template, size, setting + + +class LogType: INFO = 'INFO' WARNING = 'WARNING' ERROR = 'ERROR' - def message(self, msg, typ=None, status=None, jsonformat=False): - """ - Writes a message to stdout - @param msg: string - message to output - if msg is of type dict, json format will be used - """ - if jsonformat: - message = {} - message['msg'] = msg - if type is not None: - message['type'] = typ - if status is not None: - message['status'] = status - print json.dumps( message ) - else: - print '{}{} {}{} {}'.format(typ if typ is not None else '', - ' ' if status is not None and typ is not None else '', - status if status is not None else '', - ':' if typ is not None else '', - msg) +def message(msg, typ=None, status=None, line=None): + """ + Writes a message to stdout + + @param msg: + message to output + @param typ: + INFO, WARNING or ERROR + @param status: + status number + """ + print >> sys.stderr, '{styp}{sdelimiter}{sstatus}{slineno}{scolon}{smgs}'.format(\ + styp=typ if typ is not None else '', + sdelimiter=' ' if status is not None and status>0 and typ is not None else '', + sstatus=status if status is not None and status>0 else '', + scolon=': ' if typ is not None or line is not None else '', + smgs=msg, + slineno=' (@{:04d})'.format(line) if line is not None else '') -def exit(status=0, message="end", typ='ERROR', doexit=True): +def exit(status=0, msg="end", typ=LogType.ERROR, src=None, doexit=True, line=None): """ Called when the program should be exit @param status: the exit status program returns to callert - @param message: - the message logged before exit + @param msg: + the msg logged before exit @param typ: - message type: 'INFO', 'WARNING' or 'ERROR' + msg type: 'INFO', 'WARNING' or 'ERROR' @param doexit: True to exit program, otherwise return """ - logger = Log() - logger.message(message, typ=typ if status!=0 else 'INFO', status=status, jsonformat=True ) + if src is not None: + msg = '{} ({})'.format(src, msg) + message(msg, typ=typ if status!=ExitCode.OK else LogType.INFO, status=status, line=line) exitcode = status if doexit: sys.exit(exitcode) +def ShortHelp(doexit=True): + """ + Show short help (usage) only - ued by own -h handling + + @param doexit: + sys.exit with OK if True + """ + print parser.description + print + parser.print_usage() + print + print "For advanced help use '{prog} -H' or '{prog} --full-help'".format(prog=os.path.basename(sys.argv[0])) + if doexit: + sys.exit(ExitCode.OK) + + +class HTTPHeader: + """ + pycurl helper class retrieving the request header + """ + def __init__(self): + self.contents = '' + + def clear(self): + self.contents = '' + + def store(self, _buffer): + self.contents = "{}{}".format(self.contents, _buffer) + + def response(self): + header = str(self.contents).split('\n') + if len(header)>0: + return header[0].rstrip() + return '' + + def contenttype(self): + for item in str(self.contents).split('\n'): + ditem = item.split(":") + if ditem[0].strip().lower()=='content-type' and len(ditem)>1: + return ditem[1].strip() + return '' + + def __str__(self): + return self.contents + + +class CustomHelpFormatter(configargparse.HelpFormatter): + """ + Class for customizing the help output + """ + + def _format_action_invocation(self, action): + """ + Reformat multiple metavar output + -d , --device , --host + to single output + -d, --device, --host + """ + + orgstr = configargparse.HelpFormatter._format_action_invocation(self, action) + if orgstr and orgstr[0] != '-': # only optional arguments + return orgstr + res = getattr(action, '_formatted_action_invocation', None) + if res: + return res + + options = orgstr.split(', ') + if len(options) <=1: + action._formatted_action_invocation = orgstr + return orgstr + + return_list = [] + for option in options: + meta = "" + arg = option.split(' ') + if len(arg)>1: + meta = arg[1] + return_list.append(arg[0]) + if len(meta) >0 and len(return_list) >0: + return_list[len(return_list)-1] += " "+meta + action._formatted_action_invocation = ', '.join(return_list) + return action._formatted_action_invocation + + # ---------------------------------------------------------------------- # Tasmota config data handling # ---------------------------------------------------------------------- -def GetFilenameReplaced(filename, configuration): +class FileType: + FILE_NOT_FOUND = None + DMP = 'dmp' + JSON = 'json' + BIN = 'bin' + UNKNOWN = 'unknown' + INCOMPLETE_JSON = 'incomplete json' + INVALID_JSON = 'invalid json' + INVALID_BIN = 'invalid bin' + + +def GetFileType(filename): + """ + Get the FileType class member of a given filename + + @param filename: + filename of the file to analyse + + @return: + FileType class member + """ + filetype = FileType.UNKNOWN + + # try filename + try: + isfile = os.path.isfile(filename) + try: + f = open(filename, "r") + try: + # try reading as json + inputjson = json.load(f) + if 'header' in inputjson: + filetype = FileType.JSON + else: + filetype = FileType.INCOMPLETE_JSON + except ValueError: + filetype = FileType.INVALID_JSON + # not a valid json, get filesize and compare it with all possible sizes + try: + size = os.path.getsize(filename) + except: + filetype = FileType.UNKNOWN + sizes = GetTemplateSizes() + + # size is one of a dmp file size + if size in sizes: + filetype = FileType.DMP + elif (size - ((len(hex(BINARYFILE_MAGIC))-2)/2)) in sizes: + # check if the binary file has the magic header + try: + inputfile = open(filename, "rb") + inputbin = inputfile.read() + inputfile.close() + if struct.unpack_from('>24) & 0xff) + minor = ((version>>16) & 0xff) + release = ((version>> 8) & 0xff) + subrelease = (version & 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 = '' + return "{:d}.{:d}.{:d}{}{}".format( major, minor, release, '.' if (major>=6 and subreleasestr!='') else '', subreleasestr) + + +def MakeValidFilename(filename): + """ + Make a valid filename + + @param filename: + filename src + + @return: + valid filename removed invalid chars and replace space with _ + """ + try: + filename = filename.decode('unicode-escape').translate(dict((ord(char), None) for char in '\/*?:"<>|')) + except: + pass + return str(filename.replace(' ','_')) + + +def MakeFilename(filename, filetype, decode_cfg): """ Replace variable within a filename @@ -863,35 +1171,223 @@ def GetFilenameReplaced(filename, configuration): @v: Tasmota version @f: - FriendlyName + friendlyname + @h: + hostname + @param filetype: + FileType.x object - creates extension if not None + @param decode_cfg: + binary config data (decrypted) - @return: New filename with replacements + @return: + New filename with replacements """ v = f1 = f2 = f3 = f4 = '' - if 'version' in configuration: - ver = int(str(configuration['version']), 0) - major = ((ver>>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) + if 'version' in decode_cfg: + v = GetVersionStr( int(str(decode_cfg['version']), 0) ) filename = filename.replace('@v', v) - if 'friendlyname' in configuration: - filename = filename.replace('@f', configuration['friendlyname'][0] ) + if 'friendlyname' in decode_cfg: + filename = filename.replace('@f', decode_cfg['friendlyname'][0] ) + if 'hostname' in decode_cfg: + filename = filename.replace('@h', decode_cfg['hostname'] ) + + filename = MakeValidFilename(filename) + ext = '' + try: + name, ext = os.path.splitext(filename) + except: + pass + if len(ext) and ext[0]=='.': + ext = ext[1:] + if filetype is not None and args.extension and (len(ext)<2 or all(c.isdigit() for c in ext)): + filename += '.'+filetype.lower() return filename +def MakeUrl(host, port=80, location=''): + """ + Create a Tasmota host url + + @param host: + hostname or IP of Tasmota host + @param port: + port number to use for http connection + @param location: + http url location + + @return: + Tasmota http url + """ + return "http://{shost}{sdelimiter}{sport}/{slocation}".format(\ + shost=host, + sdelimiter=':' if port != 80 else '', + sport=port if port != 80 else '', + slocation=location ) + + +def PullTasmotaConfig(): + """ + Pull config from Tasmota device/file + + @return: + binary config data (encrypted) or None on error + """ + + if args.device is not None: + # read config direct from device via http + + c = pycurl.Curl() + buffer = io.BytesIO() + c.setopt(c.WRITEDATA, buffer) + header = HTTPHeader() + c.setopt(c.HEADERFUNCTION, header.store) + c.setopt(c.FOLLOWLOCATION, True) + c.setopt(c.URL, MakeUrl(args.device, args.port, 'dl')) + 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.VERBOSE, False) + + responsecode = 200 + try: + c.perform() + responsecode = c.getinfo(c.RESPONSE_CODE) + response = header.response() + except Exception, e: + exit(e[0], e[1],line=inspect.getlineno(inspect.currentframe())) + finally: + c.close() + + if responsecode>=400: + exit(responsecode, 'HTTP result: {}'.format(header.response()),line=inspect.getlineno(inspect.currentframe())) + elif header.contenttype()!='application/octet-stream': + exit(ExitCode.DOWNLOAD_CONFIG_ERROR, "Device did not response properly, may be Tasmota webserver admin mode is disabled (WebServer 2)",line=inspect.getlineno(inspect.currentframe())) + encode_cfg = buffer.getvalue() + + elif args.tasmotafile is not None: + # read config from a file + if not os.path.isfile(args.tasmotafile): # check file exists + exit(ExitCode.FILE_NOT_FOUND, "File '{}' not found".format(args.tasmotafile),line=inspect.getlineno(inspect.currentframe())) + try: + tasmotafile = open(args.tasmotafile, "rb") + encode_cfg = tasmotafile.read() + tasmotafile.close() + except Exception, e: + exit(e[0], "'{}' {}".format(args.tasmotafile, e[1]),line=inspect.getlineno(inspect.currentframe())) + + else: + return None + + return encode_cfg + + +def PushTasmotaConfig(encode_cfg, host, port, username=DEFAULTS['source']['username'], password=None): + """ + Upload binary data to a Tasmota host using http + + @param encode_cfg: + encrypted binary data or filename containing Tasmota encrypted binary config + @param host: + hostname or IP of Tasmota device + @param username: + optional username for Tasmota web login + @param password + optional password for Tasmota web login + + @return + errorcode, errorstring + errorcode=0 if success, otherwise http response or exception code + """ + # ~ return 0, 'OK' + + if isinstance(encode_cfg, bytearray): + encode_cfg = str(encode_cfg) + + c = pycurl.Curl() + buffer = io.BytesIO() + c.setopt(c.WRITEDATA, buffer) + header = HTTPHeader() + c.setopt(c.HEADERFUNCTION, header.store) + c.setopt(c.FOLLOWLOCATION, True) + # get restore config page first to set internal Tasmota vars + c.setopt(c.URL, MakeUrl(host, port, 'rs?')) + 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.HTTPGET, True) + c.setopt(c.VERBOSE, False) + + responsecode = 200 + try: + c.perform() + responsecode = c.getinfo(c.RESPONSE_CODE) + except Exception, e: + c.close() + return e[0], e[1] + + if responsecode>=400: + c.close() + return responsecode, header.response() + elif header.contenttype()!='text/html': + c.close() + return ExitCode.UPLOAD_CONFIG_ERROR, "Device did not response properly, may be Tasmota webserver admin mode is disabled (WebServer 2)" + + # post data + header.clear() + c.setopt(c.HEADERFUNCTION, header.store) + c.setopt(c.POST, 1) + c.setopt(c.URL, MakeUrl(host, port, 'u2')) + try: + isfile = os.path.isfile(encode_cfg) + except: + isfile = False + if isfile: + c.setopt(c.HTTPPOST, [("file", (c.FORM_FILE, encode_cfg))]) + else: + # use as binary data + c.setopt(c.HTTPPOST, [ + ('fileupload', ( + c.FORM_BUFFER, '{sprog}_v{sver}.dmp'.format(sprog=os.path.basename(sys.argv[0]), sver=VER), + c.FORM_BUFFERPTR, encode_cfg + )), + ]) + + responsecode = 200 + try: + c.perform() + responsecode = c.getinfo(c.RESPONSE_CODE) + except Exception, e: + return e[0], e[1] + c.close() + + if responsecode>=400: + return responsecode, header.response() + elif header.contenttype()!='text/html': + return ExitCode.UPLOAD_CONFIG_ERROR, "Device did not response properly, may be Tasmota webserver admin mode is disabled (WebServer 2)" + + return 0, 'OK' + + +def DecryptEncrypt(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) + """ + if isinstance(obj, bytearray): + obj = str(obj) + dobj = obj[0:2] + for i in range(2, len(obj)): + dobj += chr( (ord(obj[i]) ^ (CONFIG_FILE_XOR +i)) & 0xff ) + return dobj + + def GetSettingsCrc(dobj): """ Return binary config data calclulated crc @@ -899,121 +1395,173 @@ def GetSettingsCrc(dobj): @param dobj: decrypted binary config data - @return: 2 byte unsigned integer crc value + @return: + 2 byte unsigned integer crc value """ + if isinstance(dobj, bytearray): + dobj = str(dobj) crc = 0 for i in range(0, len(dobj)): if not i in [14,15]: # Skip crc - crc += ord(dobj[i]) * (i+1) + byte = ord(dobj[i]) + crc += byte * (i+1) + return crc & 0xffff -def GetFieldFormat(fielddef): +def GetFieldDef(fielddef): + """ - Return the format item of field definition + Get the field def items @param fielddef: field format - see "Settings dictionary" above - @return: from fielddef[0] - + @return: + , , , , , + undefined items can be None """ - return fielddef[0] + _format = baseaddr = datadef = convert = None + bits = bitshift = 0 + if len(fielddef)==3: + # def without convert tuple + _format, baseaddr, datadef = fielddef + elif len(fielddef)==4: + # def with convert tuple + _format, baseaddr, datadef, convert = fielddef + + if isinstance(baseaddr, (list,tuple)): + baseaddr, bits, bitshift = baseaddr + + if isinstance(datadef, int): + # convert single int into list with one item + datadef = [datadef] + return _format, baseaddr, bits, bitshift, datadef, convert -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): +def MakeFieldBaseAddr(baseaddr, bits, bitshift): """ Return a based on given arguments @param baseaddr: baseaddr from Settings definition - @param bitlen: - 0 or bitlen + @param bits: + 0 or bits @param bitshift: 0 or bitshift - @return: (,,) if bitlen != 0 - baseaddr if bitlen == 0 + @return: + (,,) if bits != 0 + baseaddr if bits == 0 """ - if bitlen!=0: - return (baseaddr, bitlen, bitshift) + if bits!=0: + return (baseaddr, bits, bitshift) return baseaddr -def ConvertFieldValue(value, fielddef, raw=False): +def ConvertFieldValue(value, fielddef, read=True, raw=False): """ Convert field value based on field desc @param value: - original value read from binary data + original value @param fielddef field definition - see "Settings dictionary" above + @param read + use read conversion if True, otherwise use write conversion @param raw return raw values (True) or converted values (False) - @return: (un)converted value + @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) + _format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(fielddef) + + # call password functions even if raw value should be processed + if callable(convert) and (convert==passwordread or convert==passwordwrite): + raw = False + if isinstance(convert, (list,tuple)) and len(convert)>0 and (convert[0]==passwordread or convert[0]==passwordwrite): + raw = False + if isinstance(convert, (list,tuple)) and len(convert)>1 and (convert[1]==passwordread or convert[1]==passwordwrite): + raw = False + + if not raw and convert is not None: + if isinstance(convert, (list,tuple)): # extract read conversion if tuple is given + if read: + convert = convert[0] + else: + convert = convert[1] + try: + if isinstance(convert, str): # evaluate strings + return eval(convert.replace('$','value')) + elif callable(convert): # use as format function + return convert(value) + except: + pass + return value -def GetFieldLength(fielddef): +def GetFieldMinMax(fielddef): """ - Return length of a field in bytes based on field format definition + Get minimum, maximum of field based on field format definition @param fielddef: field format - see "Settings dictionary" above - @return: length of field in bytes + @return: + min, max + """ + minmax = {'c': (0, 1), + '?': (0, 1), + 'b': (~0x7f, 0x7f), + 'B': (0, 0xff), + 'h': (~0x7fff, 0x7fff), + 'H': (0, 0xffff), + 'i': (~0x7fffffff, 0x7fffffff), + 'I': (0, 0xffffffff), + 'l': (~0x7fffffff, 0x7fffffff), + 'L': (0, 0xffffffff), + 'q': (~0x7fffffffffffffff, 0x7fffffffffffffff), + 'Q': (0, 0x7fffffffffffffff), + 'f': (sys.float_info.min, sys.float_info.max), + 'd': (sys.float_info.min, sys.float_info.max), + } + _format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(fielddef) + _min = 0 + _max = 0 + + if _format[-1:] in minmax: + _min, _max = minmax[_format[-1:]] + elif _format[-1:] in ['s','p']: + # s and p may have a prefix as length + match = re.search("\s*(\d+)", _format) + if match: + _max=int(match.group(0)) + return _min,_max + +def GetFieldLength(fielddef): + """ + Get 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] + _format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(fielddef) if datadef is not None: - # fielddef[2] contains a array or int + # datadef contains a list # 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 ): + if isinstance(datadef, list): + for i in range(0, datadef[0]): # multidimensional array if isinstance(datadef, list) and len(datadef)>1: @@ -1024,35 +1572,62 @@ def GetFieldLength(fielddef): length += GetFieldLength( (fielddef[0], fielddef[1], None) ) else: - if isinstance(fielddef[0], dict): - # -> iterate through format_ - addr = -1 - setting = fielddef[0] + if isinstance(_format, dict): + # -> iterate through _format + addr = None + setting = _format for name in setting: - baseaddr, bitlen, bitshift = GetFieldBaseAddr(setting[name]) - len_ = GetFieldLength(setting[name]) + _dummy1, baseaddr, bits, bitshift, _dummy2, _dummy3 = GetFieldDef(setting[name]) + _len = GetFieldLength(setting[name]) if addr != baseaddr: addr = baseaddr - length += len_ + length += _len else: - if format_[-1:].lower() in ['b','c','?']: + if _format[-1:] in ['b','B','c','?']: length=1 - elif format_[-1:].lower() in ['h']: + elif _format[-1:] in ['h','H']: length=2 - elif format_[-1:].lower() in ['i','l','f']: + elif _format[-1:] in ['i','I','l','L','f']: length=4 - elif format_[-1:].lower() in ['q','d']: + elif _format[-1:] in ['q','Q','d']: length=8 - elif format_[-1:].lower() in ['s','p']: + elif _format[-1:] in ['s','p']: # s and p may have a prefix as length - match = re.search("\s*(\d+)", format_) + match = re.search("\s*(\d+)", _format) if match: length=int(match.group(0)) return length +def GetSubfieldDef(fielddef): + """ + Get subfield definition from a given field definition + + @param fielddef: + see Settings desc above + + @return: + subfield definition + """ + subfielddef = None + + _format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(fielddef) + if isinstance(datadef, list) and len(datadef)>1: + if len(fielddef)<4: + subfielddef = (_format, MakeFieldBaseAddr(baseaddr, bits, bitshift), datadef[1:]) + else: + subfielddef = (_format, MakeFieldBaseAddr(baseaddr, bits, bitshift), datadef[1:], convert) + # single array + else: + if len(fielddef)<4: + subfielddef = (_format, MakeFieldBaseAddr(baseaddr, bits, bitshift), None) + else: + subfielddef = (_format, MakeFieldBaseAddr(baseaddr, bits, bitshift), None, convert) + return subfielddef + + def GetField(dobj, fieldname, fielddef, raw=False, addroffset=0): """ Get field value from definition @@ -1068,206 +1643,530 @@ def GetField(dobj, fieldname, fielddef, raw=False, addroffset=0): @param addroffset use offset for baseaddr (used for recursive calls) - @return: read field value + @return: + read field value """ + if isinstance(dobj, bytearray): + dobj = str(dobj) + result = None - # get format from field definition - format_ = GetFieldFormat(fielddef) + # get field definition + _format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(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: + # contains a integer list + if isinstance(datadef, list): result = [] + offset = 0 + for i in range(0, datadef[0]): + subfielddef = GetSubfieldDef(fielddef) + length = GetFieldLength(subfielddef) + if length != 0 and (fieldname != 'raw' or args.jsonrawkeys): + result.append(GetField(dobj, fieldname, subfielddef, raw=raw, addroffset=addroffset+offset)) + offset += length - # 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): + # contains a dict + elif isinstance(_format, dict): + config = {} + for name in _format: # -> iterate through _format + if name != 'raw' or args.jsonrawkeys: + config[name] = GetField(dobj, name, _format[name], raw=raw, addroffset=addroffset) + result = config - offset = 0 - for i in range(0, datadef[0] if isinstance(datadef, list) else datadef): + # a simple value + elif isinstance(_format, (str, bool, int, float, long)): + if GetFieldLength(fielddef) != 0: + result = struct.unpack_from(_format, dobj, baseaddr+addroffset)[0] - # 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 + if not _format[-1:].lower() in ['s','p']: + if bitshift>=0: + result >>= bitshift else: - if len(fielddef)<4: - subfielddef = (fielddef[0], MakeFieldBaseAddr(baseaddr, bitlen, bitshift), None) - else: - subfielddef = (fielddef[0], MakeFieldBaseAddr(baseaddr, bitlen, bitshift), None, fielddef[3]) + result <<= abs(bitshift) + if bits>0: + result &= (1< 127 + result = unicode(s, errors='ignore') + + result = ConvertFieldValue(result, fielddef, read=True, raw=raw) 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) + exit(ExitCode.INTERNAL_ERROR, "Wrong mapping format definition: '{}'".format(_format), typ=LogType.WARNING, doexit=not args.ignorewarning, line=inspect.getlineno(inspect.currentframe())) return result -def DeEncrypt(obj): +def SetField(dobj, fieldname, fielddef, restore, raw=False, addroffset=0, filename=""): """ - Decrpt/Encrypt binary config data + Get field value from definition - @param obj: - binary config data - - @return: decrypted configuration (if obj contains encrypted data) - encrypted configuration (if obj contains decrypted data) + @param dobj: + decrypted binary config data + @param fieldname: + name of the field + @param fielddef: + see Settings desc above + @param raw + handle values as raw values (True) or converted (False) + @param addroffset + use offset for baseaddr (used for recursive calls) + @param restore + restore mapping with the new value(s) """ - dobj = obj[0:2] - for i in range(2, len(obj)): - dobj += chr( (ord(obj[i]) ^ (CONFIG_FILE_XOR +i)) & 0xff ) + _format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(fielddef) + fieldname = str(fieldname) + + # do not write readonly values + if isinstance(convert, (list,tuple)) and len(convert)>1 and convert[1]==None: + if args.debug: + print >> sys.stderr, "SetField(): Readonly '{}' using '{}'/{}{} @{} skipped".format(fieldname, _format, datadef, bits, hex(baseaddr+addroffset)) + return dobj + + # contains a list + if isinstance(datadef, list): + offset = 0 + if len(restore)>datadef[0]: + exit(ExitCode.RESTORE_DATA_ERROR, "file '{sfile}', array '{sname}[{selem}]' exceeds max number of elements [{smax}]".format(sfile=filename, sname=fieldname, selem=len(restore), smax=datadef[0]), typ=LogType.WARNING, doexit=not args.ignorewarning, line=inspect.getlineno(inspect.currentframe())) + for i in range(0, datadef[0]): + subfielddef = GetSubfieldDef(fielddef) + length = GetFieldLength(subfielddef) + if length != 0: + if i>=len(restore): # restore data list may be shorter than definition + break + try: + subrestore = restore[i] + dobj = SetField(dobj, fieldname, subfielddef, subrestore, raw=raw, addroffset=addroffset+offset, filename=filename) + except: + pass + offset += length + + # contains a dict + elif isinstance(_format, dict): + for name in _format: # -> iterate through _format + if name in restore: + dobj = SetField(dobj, name, _format[name], restore[name], raw=raw, addroffset=addroffset, filename=filename) + + # a simple value + elif isinstance(_format, (str, bool, int, float, long)): + valid = True + err = "outside range" + + _min, _max = GetFieldMinMax(fielddef) + value = _value = valid = None + # simple one value + if _format[-1:] in ['c']: + try: + value = ConvertFieldValue(restore.encode(STR_ENCODING)[0], fielddef, read=False, raw=raw) + except: + valid = False + # bool + elif _format[-1:] in ['?']: + try: + value = ConvertFieldValue(bool(restore), fielddef, read=False, raw=raw) + except: + valid = False + # integer + elif _format[-1:] in ['b','B','h','H','i','I','l','L','q','Q','P']: + try: + value = ConvertFieldValue(restore, fielddef, read=False, raw=raw) + if isinstance(value, (str, unicode)): + value = int(value, 0) + else: + value = int(value) + # bits + if bits!=0: + value = struct.unpack_from(_format, dobj, baseaddr+addroffset)[0] + bitvalue = int(restore) + mask = (1<mask: + _min = 0 + _max = mask + _value = bitvalue + valid = False + else: + if bitshift>=0: + bitvalue <<= bitshift + mask <<= bitshift + else: + bitvalue >>= abs(bitshift) + mask >>= abs(bitshift) + value &= (0xffffffff ^ mask) + value |= bitvalue + else: + _value = value + except: + valid = False + # float + elif _format[-1:] in ['f','d']: + try: + value = ConvertFieldValue(float(restore), fielddef, read=False, raw=raw) + except: + valid = False + # string + elif _format[-1:] in ['s','p']: + try: + value = ConvertFieldValue(restore.encode(STR_ENCODING), fielddef, read=False, raw=raw) + # be aware 0 byte at end of string (str must be < max, not <= max) + _max -= 1 + valid = (len(value)>=_min) and (len(value)<=_max) + err = "string exceeds max length" + except: + valid = False + + if value is None: + valid = False + if valid is None: + valid = (value>=_min) and (value<=_max) + if _value is None: + _value = value + if isinstance(value, (str, unicode)): + _value = "'{}'".format(_value) + + if valid: + if args.debug: + if bits: + sbits=" {} bits shift {}".format(bits, bitshift) + else: + sbits = "" + print >> sys.stderr, "SetField(): Set '{}' using '{}'/{}{} @{} to {}".format(fieldname, _format, datadef, sbits, hex(baseaddr+addroffset), _value) + struct.pack_into(_format, dobj, baseaddr+addroffset, value) + else: + exit(ExitCode.RESTORE_DATA_ERROR, "file '{sfile}', value for name '{sname}': {svalue} {serror} [{smin},{smax}]".format(sfile=filename, sname=fieldname, serror=err, svalue=_value, smin=_min, smax=_max), typ=LogType.WARNING, doexit=not args.ignorewarning) + return dobj -def GetTemplateSetting(version): +def Bin2Mapping(decode_cfg, raw=True): """ - Search for template, settings and size to be used depending on given version number + Decodes binary data stream into pyhton mappings dict - @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: + @param decode_cfg: binary config data (decrypted) - @param raw + @param raw: decode raw values (True) or converted values (False) - @return: configuration dictionary + @return: + config data as mapping dictionary """ - # get header data - version = GetField(obj, 'version', Setting_6_2_1['version'], raw=True) + if isinstance(decode_cfg, bytearray): + decode_cfg = str(decode_cfg) + + # get binary header and template to use + version, template, size, setting = GetTemplateSetting(decode_cfg) - 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) ) + exit(ExitCode.UNSUPPORTED_VERSION, "Tasmota configuration version 0x{:x} not supported".format(version),line=inspect.getlineno(inspect.currentframe())) # check size if exists if 'cfg_size' in setting: - cfg_size = GetField(obj, 'cfg_size', setting['cfg_size'], raw=True) + cfg_size = GetField(decode_cfg, '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) + exit(ExitCode.DATA_SIZE_MISMATCH, "Number of bytes read does ot match - read {}, expected {} byte".format(cfg_size, template[1]), typ=LogType.ERROR,line=inspect.getlineno(inspect.currentframe())) 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') + exit(ExitCode.DATA_SIZE_MISMATCH, "Number of bytes read to small to process - read {}, expected {} byte".format(cfg_size, template[1]), typ=LogType.ERROR,line=inspect.getlineno(inspect.currentframe())) # check crc if exists if 'cfg_crc' in setting: - cfg_crc = GetField(obj, 'cfg_crc', setting['cfg_crc'], raw=True) + cfg_crc = GetField(decode_cfg, '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) + cfg_crc = GetSettingsCrc(decode_cfg) + if cfg_crc != GetSettingsCrc(decode_cfg): + exit(ExitCode.DATA_CRC_ERROR, 'Data CRC error, read 0x{:x} should be 0x{:x}'.format(cfg_crc, GetSettingsCrc(decode_cfg)), typ=LogType.WARNING, doexit=not args.ignorewarning,line=inspect.getlineno(inspect.currentframe())) # get config - config = GetField(obj, None, (setting,None,None), raw=raw) + config = GetField(decode_cfg, 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, + config['header'] = {'timestamp':timestamp.strftime("%Y-%m-%d %H:%M:%S"), + 'format': { + 'jsonindent': args.jsonindent, + 'jsoncompact': args.jsoncompact, + 'jsonsort': args.jsonsort, + 'jsonrawvalues':args.jsonrawvalues, + 'jsonrawkeys': args.jsonrawkeys, + 'jsonhidepw': args.jsonhidepw, + }, + 'src': { + 'crc': hex(cfg_crc), + 'size': cfg_size, + 'version': hex(version), + }, + 'data': { + 'crc': hex(GetSettingsCrc(decode_cfg)), + 'size': len(decode_cfg), + 'version': hex(template[0]), + }, + 'script': { + 'name': os.path.basename(__file__), + 'version': VER, + }, + 'os': (platform.machine(), platform.system(), platform.release(), platform.version(), platform.platform()), + 'python': platform.python_version(), } 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.') +def Mapping2Bin(decode_cfg, jsonconfig, filename=""): + """ + Encodes into binary data stream - source = parser.add_argument_group('source') - source.add_argument('-f', '--file', + @param decode_cfg: + binary config data (decrypted) + @param jsonconfig: + restore data mapping + @param filename: + name of the restore file (for error output only) + + @return: + changed binary config data (decrypted) + """ + if isinstance(decode_cfg, str): + decode_cfg = bytearray(decode_cfg) + + + # get binary header data to use the correct version template from device + version, template, size, setting = GetTemplateSetting(decode_cfg) + + _buffer = bytearray() + _buffer.extend(decode_cfg) + + if template is not None: + try: + raw = jsonconfig['header']['format']['jsonrawvalues'] + except: + if 'header' not in jsonconfig: + errkey = 'header' + elif 'format' not in jsonconfig['header']: + errkey = 'header.format' + elif 'jsonrawvalues' not in jsonconfig['header']['format']: + errkey = 'header.format.jsonrawvalues' + exit(ExitCode.RESTORE_DATA_ERROR, "Restore file '{sfile}' name '{skey}' missing, don't know how to evaluate restore data!".format(sfile=filename, skey=errkey), typ=LogType.ERROR, doexit=not args.ignorewarning) + + # iterate through restore data mapping + for name in jsonconfig: + # key must exist in both dict + if name in setting: + SetField(_buffer, name, setting[name], jsonconfig[name], raw=raw, addroffset=0, filename=filename) + else: + if name != 'header': + exit(ExitCode.RESTORE_DATA_ERROR, "Restore file '{}' contains obsolete name '{}', skipped".format(filename, name), typ=LogType.WARNING, doexit=not args.ignorewarning) + + crc = GetSettingsCrc(_buffer) + struct.pack_into(setting['cfg_crc'][0], _buffer, setting['cfg_crc'][1], crc) + return _buffer + + else: + exit(ExitCode.UNSUPPORTED_VERSION,"File '{}', Tasmota configuration version 0x{:x} not supported".format(filename, version), typ=LogType.WARNING, doexit=not args.ignorewarning) + + return decode_cfg + + +def Backup(backupfile, backupfileformat, encode_cfg, decode_cfg, configuration): + """ + Create backup file + + @param backupfile: + Raw backup filename from program args + @param backupfileformat: + Backup file format + @param encode_cfg: + binary config data (encrypted) + @param decode_cfg: + binary config data (decrypted) + @param configuration: + config data mapppings + """ + + backupfileformat = args.backupfileformat + try: + name, ext = os.path.splitext(backupfile) + if ext.lower() == '.'+FileType.BIN.lower(): + backupfileformat = FileType.BIN + elif ext.lower() == '.'+FileType.DMP.lower(): + backupfileformat = FileType.DMP + elif ext.lower() == '.'+FileType.JSON.lower(): + backupfileformat = FileType.JSON + except: + pass + + fileformat = "" + # binary format + if backupfileformat.lower() == FileType.BIN.lower(): + fileformat = "binary" + backup_filename = MakeFilename(backupfile, FileType.BIN, configuration) + try: + backupfp = open(backup_filename, "wb") + magic = BINARYFILE_MAGIC + backupfp.write(struct.pack('> sys.stderr, parser.format_values() + print >> sys.stderr, "Settings:" + for k in args.__dict__: + print >> sys.stderr, " "+str(k), "= ",eval('args.{}'.format(k)) + return args + + +if __name__ == "__main__": + args = ParseArgs() + if args.shorthelp: + ShortHelp() + # default no configuration available - configobj = None + encode_cfg = 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") + exit(ExitCode.ARGUMENT_ERROR, "Unable to select source, do not use -d and -f together",line=inspect.getlineno(inspect.currentframe())) - # read config direct from device via http - if args.device is not None: + # pull config from Tasmota device/file + encode_cfg = PullTasmotaConfig() + if encode_cfg is None: + # no config source given + ShortHelp(False) + print + print parser.epilog + sys.exit(ExitCode.OK) - 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) ) + if len(encode_cfg) == 0: + exit(ExitCode.FILE_READ_ERROR, "Unable to 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) \ + ,line=inspect.getlineno(inspect.currentframe()) ) + # decrypt Tasmota config + decode_cfg = DecryptEncrypt(encode_cfg) - configobj = buffer.getvalue() + # decode into mappings dictionary + configuration = Bin2Mapping(decode_cfg, args.jsonrawvalues) - # read config from a file - elif args.tasmotafile is not None: + # backup to file + if args.backupfile is not None: + Backup(args.backupfile, args.backupfileformat, encode_cfg, decode_cfg, configuration) - 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]) + # restore from file + if args.restorefile is not None: + Restore(args.restorefile, encode_cfg, decode_cfg, configuration) - # 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('