From dd00af3fe0f74494190602829244ebbda522695a Mon Sep 17 00:00:00 2001 From: Norbert Richter Date: Mon, 12 Nov 2018 16:08:09 +0100 Subject: [PATCH] decode-config.py: add features Tasmota cmnds, grouping and filters - add restore value validation - add '--force-restore' - add output filter by Tasmota groups '--filter [...]' - add '--output' and '--output-format cmnd' (adds - most of all - Tasomta command output) - add Tasomta command output control: '--cmnd-indent', '--cmnd-groups', '--cmnd-nogroups', '--cmnd-sort', '--cmnd-unsort' - removed 'raw' data handling (obsolete) --- tools/decode-config.html | 281 +++-- tools/decode-config.md | 177 +++- tools/decode-config.py | 2083 +++++++++++++++++++++++--------------- 3 files changed, 1606 insertions(+), 935 deletions(-) diff --git a/tools/decode-config.html b/tools/decode-config.html index dd3d43d01..4fa7b4e04 100644 --- a/tools/decode-config.html +++ b/tools/decode-config.html @@ -1,5 +1,11 @@

decode-config.py

-

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

+

decode-config.py is able to backup and restore Sonoff-Tasmota configuration.

+

In contrast to the Tasmota build-in "Backup/Restore Configuration" function,

+
    +
  • decode-config.py uses human readable and editable JSON-format for backup/restore,
  • +
  • decode-config.py can restore previous backuped and changed JSON-format files,
  • +
  • decode-config.py is able to create Tasomta commands based on given configuration
  • +

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

@@ -32,29 +38,35 @@
-

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

+

decode-config.py is able to handle Tasmota configurations for release version starting from 5.10.0 up to now.

Content

    -
  • Prerequisite
  • -
  • File Types
      -
    • .dmp File Format
    • -
    • .json File Format
    • -
    • .bin File Format
        -
      • File extensions
      • +
      • Prerequisite
      • +
      • File Types
      • -
      • Usage
          -
        • Basics
        • -
        • Save backup file
        • -
        • Restore backup file
        • -
        • Configuration file
        • -
        • More program arguments
        • -
        • Examples

          Prerequisite

            -
          • Python)
            This program is written in Python) so you need to install a python environment (for details see Python Setup and Usage)
          • -
          • Sonoff-Tasmota Firmware 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). -
            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.
          • +
          • Python)
            This program is written in Python) so you need to install a python environment (for details see Python Setup and Usage)

            +
          • +
          • Sonoff-Tasmota Firmware with Web-Server enabled:

            +
              +
            • 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).
            • +
            • If using your own compiled firmware be aware to enable the web-server (#define USE_WEBSERVER and #define WEB_SERVER 2).
            • +
            +

          File Types

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

          @@ -83,7 +100,7 @@

          The source can be either

          • a Tasmota device hostname or IP by passing it using the -d <host> arg
          • -
          • or a previously stored Tasmota *.dmpconfiguration file by passing the filename using-f ` arg
          • +
          • or a previously stored Tasmota *.dmp configuration file by passing the filename using -f <filename> arg

          Example:

          decode-config.py -d sonoff-4281
          @@ -112,6 +129,72 @@
           
          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
          +

          Output to screen

          +

          Output to screen is default enabled when calling the program with a source arg but without a backup or restore arg.

          +

          --output arg will force screen output even if you use backup or restore arg.

          +

          JSON output

          +

          The default output format is JSON. You can force JSON output with --output-format json arg.

          +

          Example:

          +
          decode-config.py -d sonoff-4281 -c my.conf -x Wifi --output-format json
          +
          +{
          +  ...
          +  "hostname": "%s-%04d", 
          +  "ip_address": [
          +    "0.0.0.0", 
          +    "192.168.12.1", 
          +    "255.255.255.0", 
          +    "192.168.12.1"
          +  ], 
          +  "ntp_server": [
          +    "ntp.localnet.home", 
          +    "ntp2.localnet.home", 
          +    "192.168.12.1"
          +  ], 
          +  "sta_active": 0, 
          +  "sta_config": 5, 
          +  "sta_pwd": [
          +    "myWlAnPaszxwo!z", 
          +    "myWlAnPaszxwo!z2"
          +  ], 
          +  "sta_ssid": [
          +    "wlan.1", 
          +    "my-wlan"
          +  ], 
          +  "web_password": "myPaszxwo!z", 
          +  "webserver": 2
          +  ...
          +}
          +

          Note: JSON output always contains all configuration data like the backup file except you are using --group arg.

          +

          Tasmota command output

          +

          decode-config.py is able to translate the configuration data to (most all) Tasmota commands. To output your configuration as Tasmota commands use --output-format cmnd or --output-format command.

          +

          Example:

          +
          decode-config.py -d sonoff-4281 -c my.conf -g Wifi --output-format cmnd
          +
          +# Wifi:
          +  AP 0
          +  Hostname %s-%04d
          +  IPAddress1 0.0.0.0
          +  IPAddress2 192.168.12.1
          +  IPAddress3 255.255.255.0
          +  IPAddress4 192.168.12.1
          +  NtpServer1 ntp.localnet.home
          +  NtpServer2 ntp2.localnet.home
          +  NtpServer3 192.168.12.1
          +  Password1 myWlAnPaszxwo!z
          +  Password2 myWlAnPaszxwo!z2
          +  SSId1 wlan.1
          +  SSId2 wlan.1
          +  WebPassword myPaszxwo!z
          +  WebServer 2
          +  WifiConfig 5
          +

          Note: A few very specific module commands like MPC230xx, KNX and some Display commands are not supported. These are still available by JSON output.

          +

          Filter data

          +

          The huge number of Tasomta configuration data can be overstrained and confusing, so the most of the configuration data are grouped into categories.

          +

          With decode-config.py the following categories are available: Display, Domoticz, Internal, KNX, Led, Logging, MCP230xx, MQTT, Main, Management, Pow, Sensor, Serial, SetOption, SonoffRF, System, Timers, Wifi

          +

          These are similary to the categories on https://github.com/arendst/Sonoff-Tasmota/wiki/Commands.

          +

          To filter outputs to a subset of groups use the -g or --group arg concatenating the grooup you want, e. g.

          +
          decode-config.py -d sonoff-4281 -c my.conf --output-format cmnd --group Main MQTT Management Wifi
           

          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.

          @@ -123,83 +206,111 @@

          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).

          +

          For better reading each short written arg (minus sign -) has a corresponding 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>]
          +
          usage: decode-config.py [-f <filename>] [-d <host>] [-P <port>]
                                   [-u <username>] [-p <password>] [-i <filename>]
          -                        [-o <filename>] [-F json|bin|dmp] [-E] [-e]
          +                        [-o <filename>] [-t json|bin|dmp] [-E] [-e] [-F]
                                   [--json-indent <indent>] [--json-compact]
          -                        [--json-hide-pw] [--json-unhide-pw] [-h] [-H] [-v]
          -                        [-V] [-c <filename>] [--ignore-warnings]
          +                        [--json-hide-pw] [--json-show-pw]
          +                        [--cmnd-indent <indent>] [--cmnd-groups]
          +                        [--cmnd-nogroups] [--cmnd-sort] [--cmnd-unsort]
          +                        [-c <filename>] [-S] [-T json|cmnd|command]
          +                        [-g {Display,Domoticz,Internal,KNX,Led,Logging,MCP230xx,MQTT,Main,Management,Pow,Sensor,Serial,SetOption,SonoffRF,System,Timers,Wifi} [{Display,Domoticz,Internal,KNX,Led,Logging,MCP230xx,MQTT,Main,Management,Pow,Sensor,Serial,SetOption,SonoffRF,System,Timers,Wifi} ...]]
          +                        [--ignore-warnings] [-h] [-H] [-v] [-V]
           
          -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.
          +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.
          +
          +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 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
          +  -t, --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
          +  -F, --force-restore   force restore even configuration is identical
          +
          +JSON output:
          +  JSON 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
          +  --json-show-pw, --json-unhide-pw
          +                        unhide passwords (default)
          +
          +Tasmota command output:
          +  Tasmota command output format specification
          +
          +  --cmnd-indent <indent>
          +                        Tasmota command grouping indent level (default: '2').
          +                        0 disables indent
          +  --cmnd-groups         group Tasmota commands (default)
          +  --cmnd-nogroups       leave Tasmota commands ungrouped
          +  --cmnd-sort           sort Tasmota commands (default)
          +  --cmnd-unsort         leave Tasmota commands unsorted
          +
          +Common:
          +  Optional arguments
           
          -optional arguments:
             -c, --config <filename>
          -                        program config file - can be used to set default
          -                        command args (default: None)
          +                        program config file - can be used to set default
          +                        command args (default: None)
          +  -S, --output          display output regardsless of backup/restore usage
          +                        (default do not output on backup or restore usage)
          +  -T, --output-format json|cmnd|command
          +                        display output format (default: 'json')
          +  -g, --group {Display,Domoticz,Internal,KNX,Led,Logging,MCP230xx,MQTT,Main,Management,Pow,Sensor,Serial,SetOption,SonoffRF,System,Timers,Wifi}
          +                        limit data processing to command groups (default no
          +                        filter)
             --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
          +Info:
          +  Extra information
           
             -h, --help            show usage help message and exit
          -  -H, --full-help       show full 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
          +  -V, --version         show program's version number and exit
           
          -Either argument -d <host> or -f <filename> must be given.
          -

          Examples

          +Either argument -d <host> or -f <filename> must be given. +

          Program parameter notes

          +

          decode-config.py

          +

          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. diff --git a/tools/decode-config.md b/tools/decode-config.md index d1cb05bd8..dab16a2c8 100644 --- a/tools/decode-config.md +++ b/tools/decode-config.md @@ -1,5 +1,10 @@ # decode-config.py -_decode-config.py_ backup and restore Sonoff-Tasmota configuration. +_decode-config.py_ is able to backup and restore Sonoff-Tasmota configuration. + +In contrast to the Tasmota build-in "Backup/Restore Configuration" function, +* _decode-config.py_ uses human readable and editable [JSON](http://www.json.org/)-format for backup/restore, +* _decode-config.py_ can restore previous backuped and changed [JSON](http://www.json.org/)-format files, +* _decode-config.py_ is able to create Tasomta commands based on given configuration Comparing backup files created by *decode-config.py* and *.dmp files created by Tasmota "Backup/Restore Configuration": @@ -10,19 +15,23 @@ Comparing backup files created by *decode-config.py* and *.dmp files created by | 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. +_decode-config.py_ is able to handle Tasmota configurations for release version starting from 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) + * [.dmp File Format](decode-config.md#-dmp-format) + * [.json File Format](decode-config.md#-json-format) + * [.bin File Format](decode-config.md#-bin-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) + * [Output to screen](decode-config.md#output-to-screen) + * [JSON output](decode-config.md#json-output) + * [Tasmota command output](decode-config.md#tasmota-command-output) + * [Filter data](decode-config.md#filter-data) * [Configuration file](decode-config.md#configuration-file) * [More program arguments](decode-config.md#more-program-arguments) * [Examples](decode-config.md#examples) @@ -33,9 +42,10 @@ _decode-config.py_ handles Tasmota configurations for release version since 5.10 ## 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. + +* [Sonoff-Tasmota](https://github.com/arendst/Sonoff-Tasmota) [Firmware](https://github.com/arendst/Sonoff-Tasmota/releases) with Web-Server enabled: + * 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)). + * If using your own compiled firmware be aware to enable the web-server (`#define USE_WEBSERVER` and `#define WEB_SERVER 2`). ## File Types _decode-config.py_ can handle the following backup file types: @@ -65,7 +75,7 @@ At least pass a source where you want to read the configuration data from using 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 +* or a previously stored Tasmota `*.dmp` configuration file by passing the filename using `-f ` arg Example: @@ -109,6 +119,89 @@ with password set by WebPassword: decode-config.py -d sonoff-4281 -p --restore-file Config_Sonoff_6.2.1.json +### Output to screen +Output to screen is default enabled when calling the program with a source arg but without a backup or restore arg. + +`--output` arg will force screen output even if you use backup or restore arg. + +#### JSON output +The default output format is JSON. You can force JSON output with `--output-format json` arg. + +Example: + + decode-config.py -d sonoff-4281 -c my.conf -x Wifi --output-format json + + { + ... + "hostname": "%s-%04d", + "ip_address": [ + "0.0.0.0", + "192.168.12.1", + "255.255.255.0", + "192.168.12.1" + ], + "ntp_server": [ + "ntp.localnet.home", + "ntp2.localnet.home", + "192.168.12.1" + ], + "sta_active": 0, + "sta_config": 5, + "sta_pwd": [ + "myWlAnPaszxwo!z", + "myWlAnPaszxwo!z2" + ], + "sta_ssid": [ + "wlan.1", + "my-wlan" + ], + "web_password": "myPaszxwo!z", + "webserver": 2 + ... + } + +Note: JSON output always contains all configuration data like the backup file except you are using `--group` arg. + + +#### Tasmota command output +_decode-config.py_ is able to translate the configuration data to (most all) Tasmota commands. To output your configuration as Tasmota commands use `--output-format cmnd` or `--output-format command`. + +Example: + + decode-config.py -d sonoff-4281 -c my.conf -g Wifi --output-format cmnd + + # Wifi: + AP 0 + Hostname %s-%04d + IPAddress1 0.0.0.0 + IPAddress2 192.168.12.1 + IPAddress3 255.255.255.0 + IPAddress4 192.168.12.1 + NtpServer1 ntp.localnet.home + NtpServer2 ntp2.localnet.home + NtpServer3 192.168.12.1 + Password1 myWlAnPaszxwo!z + Password2 myWlAnPaszxwo!z2 + SSId1 wlan.1 + SSId2 wlan.1 + WebPassword myPaszxwo!z + WebServer 2 + WifiConfig 5 + +Note: A few very specific module commands like MPC230xx, KNX and some Display commands are not supported. These are still available by JSON output. + +### Filter data +The huge number of Tasomta configuration data can be overstrained and confusing, so the most of the configuration data are grouped into categories. + +With _decode-config.py_ the following categories are available: `Display`, `Domoticz`, `Internal`, `KNX`, `Led`, `Logging`, `MCP230xx`, `MQTT`, `Main`, `Management`, `Pow`, `Sensor`, `Serial`, `SetOption`, `SonoffRF`, `System`, `Timers`, `Wifi` + +These are similary to the categories on [https://github.com/arendst/Sonoff-Tasmota/wiki/Commands](Tasmota Command Wiki). + +To filter outputs to a subset of groups use the `-g` or `--group` arg concatenating the grooup you want, e. g. + + decode-config.py -d sonoff-4281 -c my.conf --output-format cmnd --group Main MQTT Management Wifi + + ### 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/)). @@ -129,7 +222,7 @@ To make a backup file from example above you can now pass the config file instea ### 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). +For better reading each short written arg (minus sign `-`) has a corresponding 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`. @@ -137,10 +230,14 @@ 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] + [-o ] [-t json|bin|dmp] [-E] [-e] [-F] [--json-indent ] [--json-compact] - [--json-hide-pw] [--json-unhide-pw] [-h] [-H] [-v] - [-V] [-c ] [--ignore-warnings] + [--json-hide-pw] [--json-show-pw] + [--cmnd-indent ] [--cmnd-groups] + [--cmnd-nogroups] [--cmnd-sort] [--cmnd-unsort] + [-c ] [-S] [-T json|cmnd|command] + [-g {Display,Domoticz,Internal,KNX,Led,Logging,MCP230xx,MQTT,Main,Management,Pow,Sensor,Serial,SetOption,SonoffRF,System,Timers,Wifi} [{Display,Domoticz,Internal,KNX,Led,Logging,MCP230xx,MQTT,Main,Management,Pow,Sensor,Serial,SetOption,SonoffRF,System,Timers,Wifi} ...]] + [--ignore-warnings] [-h] [-H] [-v] [-V] 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 @@ -148,13 +245,6 @@ For advanced help use `-H` or `--full-help`: 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 @@ -172,7 +262,7 @@ For advanced help use `-H` or `--full-help`: host HTTP access password (default: None) Backup/Restore: - Backup/Restore configuration file specification + Backup & restore specification -i, --restore-file file to restore configuration from (default: None). @@ -182,25 +272,54 @@ For advanced help use `-H` or `--full-help`: file to backup configuration to (default: None). Replacements: @v=firmware version, @f=device friendly name, @h=device hostname - -F, --backup-type json|bin|dmp + -t, --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 + -F, --force-restore force restore even configuration is identical - JSON: - JSON backup format specification + JSON output: + JSON 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 + --json-hide-pw hide passwords + --json-show-pw, --json-unhide-pw + unhide passwords (default) + + Tasmota command output: + Tasmota command output format specification + + --cmnd-indent + Tasmota command grouping indent level (default: '2'). + 0 disables indent + --cmnd-groups group Tasmota commands (default) + --cmnd-nogroups leave Tasmota commands ungrouped + --cmnd-sort sort Tasmota commands (default) + --cmnd-unsort leave Tasmota commands unsorted + + Common: + Optional arguments + + -c, --config + program config file - can be used to set default + command args (default: None) + -S, --output display output regardsless of backup/restore usage + (default do not output on backup or restore usage) + -T, --output-format json|cmnd|command + display output format (default: 'json') + -g, --group {Display,Domoticz,Internal,KNX,Led,Logging,MCP230xx,MQTT,Main,Management,Pow,Sensor,Serial,SetOption,SonoffRF,System,Timers,Wifi} + limit data processing to command groups (default no + filter) + --ignore-warnings do not exit on warnings. Not recommended, used by your + own responsibility! Info: - additional information + Extra information -h, --help show usage help message and exit -H, --full-help show full help message and exit @@ -209,6 +328,10 @@ For advanced help use `-H` or `--full-help`: Either argument -d or -f must be given. +### Program parameter notes + +_decode-config.py_ + ### Examples The most of the examples are for linux command line. Under Windows call the program using `python decode-config.py ...`. diff --git a/tools/decode-config.py b/tools/decode-config.py index 187eec5f3..d6632dbda 100755 --- a/tools/decode-config.py +++ b/tools/decode-config.py @@ -1,13 +1,13 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -VER = '2.0.0005' +VER = '2.1.0006' """ decode-config.py - Backup/Restore Sonoff-Tasmota configuration data Copyright (C) 2018 Norbert Richter - This program is free software: you can redistribute it and/or modify + This program is free software: you can redistribute it and/or modfy 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. @@ -37,10 +37,14 @@ Instructions: Usage: decode-config.py [-f ] [-d ] [-P ] [-u ] [-p ] [-i ] - [-o ] [-F json|bin|dmp] [-E] [-e] + [-o ] [-t json|bin|dmp] [-E] [-e] [-F] [--json-indent ] [--json-compact] - [--json-hide-pw] [--json-unhide-pw] [-h] [-H] [-v] - [-V] [-c ] [--ignore-warnings] + [--json-hide-pw] [--json-show-pw] + [--cmnd-indent ] [--cmnd-groups] + [--cmnd-nogroups] [--cmnd-sort] [--cmnd-unsort] + [-c ] [-S] [-T json|cmnd|command] + [-g {Display,Domoticz,Internal,KNX,Led,Logging,MCP230xx,MQTT,Main,Management,Pow,Sensor,Serial,SetOption,SonoffRF,System,Timers,Wifi} [{Display,Domoticz,Internal,KNX,Led,Logging,MCP230xx,MQTT,Main,Management,Pow,Sensor,Serial,SetOption,SonoffRF,System,Timers,Wifi} ...]] + [--ignore-warnings] [-h] [-H] [-v] [-V] 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 @@ -48,13 +52,6 @@ Usage: decode-config.py [-f ] [-d ] [-P ] 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 @@ -72,7 +69,7 @@ Usage: decode-config.py [-f ] [-d ] [-P ] host HTTP access password (default: None) Backup/Restore: - Backup/Restore configuration file specification + Backup & restore specification -i, --restore-file file to restore configuration from (default: None). @@ -82,25 +79,54 @@ Usage: decode-config.py [-f ] [-d ] [-P ] file to backup configuration to (default: None). Replacements: @v=firmware version, @f=device friendly name, @h=device hostname - -F, --backup-type json|bin|dmp + -t, --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 + -F, --force-restore force restore even configuration is identical - JSON: - JSON backup format specification + JSON output: + JSON 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 + --json-hide-pw hide passwords + --json-show-pw, --json-unhide-pw + unhide passwords (default) + + Tasmota command output: + Tasmota command output format specification + + --cmnd-indent + Tasmota command grouping indent level (default: '2'). + 0 disables indent + --cmnd-groups group Tasmota commands (default) + --cmnd-nogroups leave Tasmota commands ungrouped + --cmnd-sort sort Tasmota commands (default) + --cmnd-unsort leave Tasmota commands unsorted + + Common: + Optional arguments + + -c, --config + program config file - can be used to set default + command args (default: None) + -S, --output display output regardsless of backup/restore usage + (default do not output on backup or restore usage) + -T, --output-format json|cmnd|command + display output format (default: 'json') + -g, --group {Display,Domoticz,Internal,KNX,Led,Logging,MCP230xx,MQTT,Main,Management,Pow,Sensor,Serial,SetOption,SonoffRF,System,Timers,Wifi} + limit data processing to command groups (default no + filter) + --ignore-warnings do not exit on warnings. Not recommended, used by your + own responsibility! Info: - additional information + Extra information -h, --help show usage help message and exit -H, --full-help show full help message and exit @@ -146,6 +172,9 @@ class ExitCode: MODULE_NOT_FOUND = 20 INTERNAL_ERROR = 21 +# ====================================================================== +# imports +# ====================================================================== import os.path import io import sys, platform @@ -155,6 +184,7 @@ def ModuleImportError(module): sys.exit(ExitCode.MODULE_NOT_FOUND) try: from datetime import datetime + import time import copy import struct import socket @@ -168,18 +198,18 @@ try: except ImportError, e: ModuleImportError(e) - +# ====================================================================== +# globals +# ====================================================================== PROG='{} v{} by Norbert Richter '.format(os.path.basename(sys.argv[0]),VER) CONFIG_FILE_XOR = 0x5A BINARYFILE_MAGIC = 0x63576223 STR_ENCODING = 'utf8' +HIDDEN_PASSWORD = '********' +INTERNAL = 'Internal' + DEFAULTS = { - 'DEFAULT': - { - 'configfile': None, - 'ignorewarning':False, - }, 'source': { 'device': None, @@ -194,483 +224,542 @@ DEFAULTS = { 'backupfile': None, 'backupfileformat': 'json', 'extension': True, + 'forcerestore': False, }, 'jsonformat': { 'jsonindent': None, 'jsoncompact': False, 'jsonsort': True, - 'jsonrawvalues':False, - 'jsonrawkeys': False, 'jsonhidepw': False, }, + 'cmndformat': + { + 'cmndindent': 2, + 'cmndgroup': True, + 'cmndsort': True, + }, + 'common': + { + 'output': False, + 'outputformat': 'json', + 'configfile': None, + 'ignorewarning':False, + 'filter': None, + }, } args = {} exitcode = 0 +# ====================================================================== +# Settings mapping +# ====================================================================== """ Settings dictionary describes the config file fields definition: - Each setting name has a tuple containing the following items: + = { : } + + : "string" + a python valid dictionary key (string) - (format, baseaddr, datadef, ) + : ( , , [,] ) + a tuple containing the following items: - where + : | + data type & format definition + : + defines the use of data at + format is defined in 'struct module format string' + see + https://docs.python.org/2.7/library/struct.html#format-strings + : + A dictionary describes a (sub)setting dictonary + and can recursively define another - format - Define the data interpretation. - It is either a string or a tuple containing a string and a - sub-Settings dictionary. - 'xxx': - A string is used to interpret the data at - The string defines the format interpretion as described - in 'struct module format string', see - https://docs.python.org/2.7/library/struct.html#format-strings - In addition to this format string there is as special - meaning of a dot '.' - this means a bit with an optional - prefix length. If no prefix is given, 1 is assumed. - {}: - A dictionary describes itself a 'Settings' dictonary (recursive) + : | (, , ) + address definition + : + The address (starting from 0) within binary config data. + : + number of bits used (positive integer) + : + bit shift : + >= 0: shift the result right + < 0: shift the result left - baseaddr - The address (starting from 0) within config data. - For bit fields must be a tuple. - n: - Defines a simple address within config data. - must be a positive integer. - (n, b, s): - A tuple defines a bit field: - - is the address within config data (integer) - - how many bits are used (positive integer) - - bit shift (integer) - positive shift the result right bits - negative shift the result left bits - - datadef - Data definition, is either a array definition or a - tuple containing an array definition and min/max values - Format: arraydef|(arraydef, min, max) - arraydef: + : | (, [,cmd]) + data definition + : None | | [] | [ ,...] None: - None must be given if the field contains a - simple value desrcibed by the prefix - n: - [n]: + Single value, not an array + : + [] Defines a one-dimensional array of size - [n, m <,o...>] - Defines a multi-dimensional array - min: - defines a minimum valid value or None if all values - for this format is allowed. - max: - defines a maximum valid value or None if all values - for this format is allowed. + [ ,...] + Defines a one- or multi-dimensional array + : + value validation function + : (, ) + Tasmota command definition + : + command group string + : + convert data into Tasmota command function - converter (optional) - Conversion methode(s): 'xxx'|func or ('xxx'|func, '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: - 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 + : | (, ) + read/write converter + : None | + Will be used in Bin2Mapping to convert values read + from the binary data object into mapping dictionary + None + None indicates not read conversion + + to convert value from binary object to JSON. + : None | False | + Will be used in Mapping2Bin to convert values read + from mapping dictionary before write to binary + data object + None + None indicates not write conversion + False + False indicates the value is readonly and will + not be written into the binary object. + + to convert value from JSON back to binary object + Common definitions + + : | | None + function to be called or string to evaluate: + : + A function name will be called with one or two parameter: + The value to be processed + (optional) the current array index (1,n) + + A string will be evaluate as is. The following + placeholder can be used to replace it by runtime values: + '$': + will be replaced by the mapping name value + '#': + will be replace by array index (if any) + '@': + can be used as reference to other mapping values + see definition below for examples + + : 'string' | "string" + characters enclosed in ' or " + + : integer + numbers in the range -2147483648 through 2147483647 + : unsigned integer + numbers in the range 0 through 4294967295 + """ +# ---------------------------------------------------------------------- +# Settings helper +# ---------------------------------------------------------------------- def passwordread(value): - return "********" if args.jsonhidepw else value + return HIDDEN_PASSWORD if args.jsonhidepw else value def passwordwrite(value): - return None if value=="********" else value + return None if value == HIDDEN_PASSWORD else value +def bitsRead(x, n=0, c=1): + """ + Reads bit(s) of a number + + @param x: + the number from which to read + + @param n: + which bit position to read + + @param c: + how many bits to read (1 if omitted) + + @return: + the bit value(s) + """ + if isinstance(x,str): + x = int(x, 0) + if isinstance(x,str): + n = int(n, 0) + if n >= 0: + x >>= n + else: + x <<= abs(n) + if c>0: + x &= (1<, , [,] + 'cfg_holder': ('0 and bitsRead($,0,11)>(12*60) else "",time=time.strftime("%H:%M",time.gmtime((bitsRead($,0,11) if bitsRead($,29,2)==0 else bitsRead($,0,11) if bitsRead($,0,11)<=(12*60) else bitsRead($,0,11)-(12*60))*60)),window=bitsRead($,11,4),repeat=bitsRead($,15),days="{:07b}".format(bitsRead($,16,7))[::-1],device=bitsRead($,23,4)+1,power=bitsRead($,27,2) )')), ('"0x{:08x}".format($)', False) ), + 'time': (' 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, size and settings to be used depending on given binary config data - - @param decode_cfg: - binary config data (decrypted) - - @return: - version, size, settings to use; None if version is invalid - """ - version = 0x0 - size = setting = None - - version = GetField(decode_cfg, 'version', Setting_6_2_1['version'], raw=True) - # search setting definition top-down - for cfg in sorted(Settings, key=lambda s: s[0], reverse=True): - if version >= cfg[0]: - version = cfg[0] - size = cfg[1] - setting = cfg[2].copy() - break - - return version, size, setting - - +# ====================================================================== +# Common helper +# ====================================================================== class LogType: INFO = 'INFO' WARNING = 'WARNING' @@ -756,8 +803,8 @@ def message(msg, typ=None, status=None, line=None): """ 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 '', + 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 '') @@ -816,14 +863,14 @@ class HTTPHeader: def response(self): header = str(self.contents).split('\n') - if len(header)>0: + 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: + if ditem[0].strip().lower() == 'content-type' and len(ditem) > 1: return ditem[1].strip() return '' @@ -852,7 +899,7 @@ class CustomHelpFormatter(configargparse.HelpFormatter): return res options = orgstr.split(', ') - if len(options) <=1: + if len(options) <= 1: action._formatted_action_invocation = orgstr return orgstr @@ -860,18 +907,83 @@ class CustomHelpFormatter(configargparse.HelpFormatter): for option in options: meta = "" arg = option.split(' ') - if len(arg)>1: + if len(arg) > 1: meta = arg[1] return_list.append(arg[0]) - if len(meta) >0 and len(return_list) >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 GetTemplateSizes(): + """ + Get all possible template sizes as list + + @param version: + 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, size and settings to be used depending on given binary config data + + @param decode_cfg: + binary config data (decrypted) + + @return: + version, size, settings to use; None if version is invalid + """ + version = 0x0 + size = setting = None + version = GetField(decode_cfg, 'version', Setting_6_2_1['version'], raw=True) + # search setting definition top-down + for cfg in sorted(Settings, key=lambda s: s[0], reverse=True): + if version >= cfg[0]: + size = cfg[1] + setting = cfg[2] + break + + return version, size, setting + + +def GetGroupList(setting): + """ + Get all avilable group definition from setting + + @return: + configargparse.parse_args() result + """ + groups = set() + + for name in setting: + dev = setting[name] + format, group = GetFieldDef(dev, fields="format, group") + if group is not None and len(group) > 0: + groups.add(group) + if isinstance(format, dict): + subgroups = GetGroupList(format) + if subgroups is not None and len(subgroups) > 0: + for group in subgroups: + groups.add(group) + + groups=list(groups) + groups.sort() + return groups + + class FileType: FILE_NOT_FOUND = None DMP = 'dmp' @@ -882,7 +994,6 @@ class FileType: INVALID_JSON = 'invalid json' INVALID_BIN = 'invalid bin' - def GetFileType(filename): """ Get the FileType class member of a given filename @@ -960,39 +1071,22 @@ def GetVersionStr(version): minor = ((version>>16) & 0xff) release = ((version>> 8) & 0xff) subrelease = (version & 0xff) - if major>=6: - if subrelease>0: + if major >= 6: + if subrelease > 0: subreleasestr = str(subrelease) else: subreleasestr = '' else: - if subrelease>0: + 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) + return "{:d}.{:d}.{:d}{}{}".format( major, minor, release, '.' if (major >= 6 and subreleasestr != '') else '', subreleasestr) -def MakeValidFilename(filename): +def MakeFilename(filename, filetype, configmapping): """ - 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 + Replace variables within a filename @param filename: original filename possible containing replacements: @@ -1004,40 +1098,49 @@ def MakeFilename(filename, filetype, decode_cfg): hostname @param filetype: FileType.x object - creates extension if not None - @param decode_cfg: + @param configmapping: binary config data (decrypted) @return: New filename with replacements """ v = f1 = f2 = f3 = f4 = '' - if 'version' in decode_cfg: - v = GetVersionStr( int(str(decode_cfg['version']), 0) ) + if 'version' in configmapping: + v = GetVersionStr( int(str(configmapping['version']), 0) ) filename = filename.replace('@v', v) - 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'] ) + if 'friendlyname' in configmapping: + filename = filename.replace('@f', configmapping['friendlyname'][0] ) + if 'hostname' in configmapping: + filename = filename.replace('@h', configmapping['hostname'] ) dirname = basename = ext = '' + name = filename + + # split file parts + dirname = os.path.normpath(os.path.dirname(filename)) + basename = os.path.basename(filename) + name, ext = os.path.splitext(basename) + + # make a valid filename try: - dirname = os.path.normpath(os.path.dirname(filename)) - basename = os.path.basename(filename) - name, ext = os.path.splitext(basename) + name = name.decode('unicode-escape').translate(dict((ord(char), None) for char in '\/*?:"<>|')) except: pass - name = MakeValidFilename(name) + name = str(name.replace(' ','_')) + # append extension based on filetype if not given 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)): ext = filetype.lower() + # join filename + extension if len(ext): name_ext = name+'.'+ext else: name_ext = name + # join path and filename try: filename = os.path.join(dirname, name_ext) except: @@ -1156,6 +1259,8 @@ def PushTasmotaConfig(encode_cfg, host, port, username=DEFAULTS['source']['usern encrypted binary data or filename containing Tasmota encrypted binary config @param host: hostname or IP of Tasmota device + @param port: + http port of Tasmota device @param username: optional username for Tasmota web login @param password @@ -1165,8 +1270,6 @@ def PushTasmotaConfig(encode_cfg, host, port, username=DEFAULTS['source']['usern 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) @@ -1192,10 +1295,10 @@ def PushTasmotaConfig(encode_cfg, host, port, username=DEFAULTS['source']['usern c.close() return e[0], e[1] - if responsecode>=400: + if responsecode >= 400: c.close() return responsecode, header.response() - elif header.contenttype()!='text/html': + 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)" @@ -1227,9 +1330,9 @@ def PushTasmotaConfig(encode_cfg, host, port, username=DEFAULTS['source']['usern return e[0], e[1] c.close() - if responsecode>=400: + if responsecode >= 400: return responsecode, header.response() - elif header.contenttype()!='text/html': + 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' @@ -1244,7 +1347,6 @@ def DecryptEncrypt(obj): @return: decrypted configuration (if obj contains encrypted data) - encrypted configuration (if obj contains decrypted data) """ if isinstance(obj, bytearray): obj = str(obj) @@ -1276,58 +1378,124 @@ def GetSettingsCrc(dobj): return crc & 0xffff -def GetFieldDef(fielddef): +def GetFieldDef(fielddef, fields="format, addrdef, baseaddr, bits, bitshift, datadef, arraydef, validate, cmd, group, tasmotacmnd, converter, readconverter, writeconverter"): """ - Get the field def items + Get field definition items @param fielddef: field format - see "Settings dictionary" above + @param fields: + comma separated string list of values to be returned + possible values see fields default @return: - , , , , , - undefined items can be None + set of values defined in """ - format = baseaddr = datadef = convert = None + format = addrdef = baseaddr = datadef = arraydef = validate = cmd = group = tasmotacmnd = converter = readconverter = writeconverter = 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 + # calling with nothing is wrong + if fielddef is None: + print >> sys.stderr, ' is None' + raise SyntaxError(' error') + + # get top level items + if len(fielddef) == 3: + # converter not present + format, addrdef, datadef = fielddef + elif len(fielddef) == 4: + # converter present + format, addrdef, datadef, converter = fielddef + else: + print >> sys.stderr, 'wrong {} length ({}) in setting'.format(fielddef, len(fielddef)) + raise SyntaxError(' error') + + # ignore calls with 'root' setting + if isinstance(format, dict) and baseaddr is None and datadef is None: + return eval(fields) + + if not isinstance(format, (unicode,str,dict)): + print >> sys.stderr, 'wrong {} type {} in {}'.format(format, type(format), fielddef) + raise SyntaxError(' error') + + # extract addrdef items + baseaddr = addrdef if isinstance(baseaddr, (list,tuple)): - baseaddr, bits, bitshift = baseaddr + if len(baseaddr) == 3: + # baseaddr bit definition + baseaddr, bits, bitshift = baseaddr + if not isinstance(bits, int): + print >> sys.stderr, ' must be a integer in {}'.format(bits, fielddef) + raise SyntaxError(' error') + if not isinstance(bitshift, int): + print >> sys.stderr, ' must be a integer in {}'.format(bitshift, fielddef) + raise SyntaxError(' error') + else: + print >> sys.stderr, 'wrong {} length ({}) in {}'.format(addrdef, len(addrdef), fielddef) + raise SyntaxError(' error') + if not isinstance(baseaddr, int): + print >> sys.stderr, ' must be a integer in {}'.format(baseaddr, fielddef) + raise SyntaxError(' error') - if isinstance(datadef, int): - # convert single int into list with one item - datadef = [datadef] - return format, baseaddr, bits, bitshift, datadef, convert + # extract datadef items + arraydef = datadef + if isinstance(datadef, (tuple)): + if len(datadef) == 2: + # datadef has a validator + arraydef, validate = datadef + elif len(datadef) == 3: + # datadef has a validator and cmd set + arraydef, validate, cmd = datadef + # cmd must be a tuple with 2 objects + if isinstance(cmd, (tuple)) and len(cmd) == 2: + group, tasmotacmnd = cmd + if group is not None and not isinstance(group, (str, unicode)): + print >> sys.stderr, 'wrong {} in {}'.format(group, fielddef) + raise SyntaxError(' error') + if tasmotacmnd is not None and not isinstance(tasmotacmnd, (str, unicode)): + print >> sys.stderr, 'wrong {} in {}'.format(tasmotacmnd, fielddef) + raise SyntaxError(' error') + else: + print >> sys.stderr, 'wrong {} length ({}) in {}'.format(cmd, len(cmd), fielddef) + raise SyntaxError(' error') + else: + print >> sys.stderr, 'wrong {} length ({}) in {}'.format(datadef, len(datadef), fielddef) + raise SyntaxError(' error') + + if validate is not None and (not isinstance(validate, (unicode,str)) and not callable(validate)): + print >> sys.stderr, 'wrong {} type {} in {}'.format(validate, type(validate), fielddef) + raise SyntaxError(' error') + + # convert single int into one-dimensional list + if isinstance(arraydef, int): + arraydef = [arraydef] + + if arraydef is not None and not isinstance(arraydef, (list)): + print >> sys.stderr, 'wrong {} type {} in {}'.format(arraydef, type(arraydef), fielddef) + raise SyntaxError(' error') + + # get read/write converter items + readconverter = converter + if isinstance(converter, (tuple)): + if len(converter) == 2: + # converter has read/write converter + readconverter, writeconverter = converter + if readconverter is not None and not isinstance(readconverter, (str,unicode)) and not callable(readconverter): + print >> sys.stderr, 'wrong {} type {} in {}'.format(readconverter, type(readconverter), fielddef) + raise SyntaxError(' error') + if writeconverter is not None and (not isinstance(writeconverter, (bool,str,unicode)) and not callable(writeconverter)): + print >> sys.stderr, 'wrong {} type {} in {}'.format(writeconverter, type(writeconverter), fielddef) + raise SyntaxError(' error') + else: + print >> sys.stderr, 'wrong {} length ({}) in {}'.format(converter, len(converter), fielddef) + raise SyntaxError(' error') + + + return eval(fields) -def MakeFieldBaseAddr(baseaddr, bits, bitshift): - """ - Return a based on given arguments - - @param baseaddr: - baseaddr from Settings definition - @param bits: - 0 or bits - @param bitshift: - 0 or bitshift - - @return: - (,,) if bits != 0 - baseaddr if bits == 0 - - """ - if bits!=0: - return (baseaddr, bits, bitshift) - return baseaddr - - -def ConvertFieldValue(value, fielddef, read=True, raw=False): +def ReadWriteConverter(value, fielddef, read=True, raw=False): """ Convert field value based on field desc @@ -1343,33 +1511,107 @@ def ConvertFieldValue(value, fielddef, read=True, raw=False): @return: (un)converted value """ - format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(fielddef) + converter, readconverter, writeconverter = GetFieldDef(fielddef, fields='converter, readconverter, writeconverter') # call password functions even if raw value should be processed - if callable(convert) and (convert==passwordread or convert==passwordwrite): + if read and callable(readconverter) and readconverter == passwordread: 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): + if not read and callable(writeconverter) and writeconverter == 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] + if not raw and converter is not None: + conv = readconverter if read else writeconverter try: - if isinstance(convert, str): # evaluate strings - return eval(convert.replace('$','value')) - elif callable(convert): # use as format function - return convert(value) - except: - pass + if isinstance(conv, str): # evaluate strings + return eval(conv.replace('$','value')) + elif callable(conv): # use as format function + return conv(value) + except Exception, e: + exit(e[0], e[1], typ=LogType.WARNING, line=inspect.getlineno(inspect.currentframe())) return value +def CmndConverter(valuemapping, value, idx, fielddef): + """ + Convert field value into Tasmota command if available + + @param valuemapping: + data mapping + @param value: + original value + @param fielddef + field definition - see "Settings dictionary" above + + @return: + converted value or None if unable to convert + """ + converter, readconverter, writeconverter, group, tasmotacmnd = GetFieldDef(fielddef, fields='converter, readconverter, writeconverter, group, tasmotacmnd') + + result = None + + if (callable(readconverter) and readconverter == passwordread) or (callable(writeconverter) and writeconverter == passwordwrite): + if value == HIDDEN_PASSWORD: + return None + else: + result = value + + if tasmotacmnd is not None and len(tasmotacmnd) > 0: + if idx is not None: + idx += 1 + if isinstance(tasmotacmnd, str): # evaluate strings + if idx is not None: + evalstr = tasmotacmnd.replace('$','value').replace('#','idx').replace('@','valuemapping') + else: + evalstr = tasmotacmnd.replace('$','value').replace('@','valuemapping') + # ~ try: + result = eval(evalstr) + # ~ except: + # ~ print evalstr + # ~ print value + + elif callable(tasmotacmnd): # use as format function + if idx is not None: + result = tasmotacmnd(value, idx) + else: + result = tasmotacmnd(value) + + return result + + +def ValidateValue(value, fielddef): + """ + Validate a value if validator is defined in fielddef + + @param value: + original value + @param fielddef + field definition - see "Settings dictionary" above + + @return: + True if value is valid, False if invalid + """ + validate = GetFieldDef(fielddef, fields='validate') + + if value == 0: + # can not complete all validate condition + # some Tasmota values are not allowed to be 0 on input + # even though these values are set to 0 on Tasmota initial. + # so we can't validate 0 values + return True; + + valid = True + try: + if isinstance(validate, str): # evaluate strings + valid = eval(validate.replace('$','value')) + elif callable(validate): # use as format function + valid = validate(value) + except: + valid = False + + return valid + + def GetFieldMinMax(fielddef): """ Get minimum, maximum of field based on field format definition @@ -1395,7 +1637,7 @@ def GetFieldMinMax(fielddef): '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) + format = GetFieldDef(fielddef, fields='format') _min = 0 _max = 0 @@ -1421,48 +1663,46 @@ def GetFieldLength(fielddef): """ length=0 - format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(fielddef) + format, addrdef, arraydef = GetFieldDef(fielddef, fields='format, addrdef, arraydef') - if datadef is not None: - # datadef contains a list + # contains a integer list + if isinstance(arraydef, list) and len(arraydef) > 0: + # arraydef contains a list # calc size recursive by sum of all elements - if isinstance(datadef, list): - for i in range(0, datadef[0]): + for i in range(0, arraydef[0]): + subfielddef = GetSubfieldDef(fielddef) + if len(arraydef) > 1: + length += GetFieldLength( (format, addrdef, subfielddef) ) + # single array + else: + length += GetFieldLength( (format, addrdef, None) ) - # multidimensional array - if isinstance(datadef, list) and len(datadef)>1: - length += GetFieldLength( (fielddef[0], fielddef[1], fielddef[2][1:]) ) - - # single array - else: - length += GetFieldLength( (fielddef[0], fielddef[1], None) ) - - else: - if isinstance(format, dict): + elif isinstance(format, dict): # -> iterate through format addr = None setting = format for name in setting: - _dummy1, baseaddr, bits, bitshift, _dummy2, _dummy3 = GetFieldDef(setting[name]) + baseaddr, bits, bitshift = GetFieldDef(setting[name], fields='baseaddr, bits, bitshift') _len = GetFieldLength(setting[name]) if addr != baseaddr: addr = baseaddr length += _len - else: - if format[-1:] in ['b','B','c','?']: - length=1 - elif format[-1:] in ['h','H']: - length=2 - elif format[-1:] in ['i','I','l','L','f']: - length=4 - elif format[-1:] in ['q','Q','d']: - length=8 - elif format[-1:] in ['s','p']: - # s and p may have a prefix as length - match = re.search("\s*(\d+)", format) - if match: - length=int(match.group(0)) + # a simple value + elif isinstance(format, str): + if format[-1:] in ['b','B','c','?']: + length=1 + elif format[-1:] in ['h','H']: + length=2 + elif format[-1:] in ['i','I','l','L','f']: + length=4 + elif format[-1:] in ['q','Q','d']: + length=8 + elif format[-1:] in ['s','p']: + # s and p may have a prefix as length + match = re.search("\s*(\d+)", format) + if match: + length=int(match.group(0)) return length @@ -1477,23 +1717,52 @@ def GetSubfieldDef(fielddef): @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 + format, addrdef, datadef, arraydef, validate, cmd, converter = GetFieldDef(fielddef, fields='format, addrdef, datadef, arraydef, validate, cmd, converter') + + # create new arraydef + if len(arraydef) > 1: + arraydef = arraydef[1:] else: - if len(fielddef)<4: - subfielddef = (format, MakeFieldBaseAddr(baseaddr, bits, bitshift), None) + arraydef = None + + # create new datadef + if isinstance(datadef, tuple): + if cmd is not None: + datadef = (arraydef, validate, cmd) else: - subfielddef = (format, MakeFieldBaseAddr(baseaddr, bits, bitshift), None, convert) + datadef = (arraydef, validate) + else: + datadef = arraydef + + # set new field def + subfielddef = None + if converter is not None: + subfielddef = (format, addrdef, datadef, converter) + else: + subfielddef = (format, addrdef, datadef) + return subfielddef +def IsFilterGroup(group): + """ + Check if group is valid on filter + + @param grooup: + group name to check + + @return: + True if group is in filter, otherwise False + """ + if args.filter is not None: + if group is None: + return False + if group != INTERNAL and group != '*' and group not in args.filter: + return False + return True + + def GetField(dobj, fieldname, fielddef, raw=False, addroffset=0): """ Get field value from definition @@ -1510,68 +1779,69 @@ def GetField(dobj, fieldname, fielddef, raw=False, addroffset=0): use offset for baseaddr (used for recursive calls) @return: - read field value + field mapping """ if isinstance(dobj, bytearray): dobj = str(dobj) - result = None - - if fieldname == 'raw' and not args.jsonrawkeys: - return result + valuemapping = None # get field definition - format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(fielddef) + format, baseaddr, bits, bitshift, arraydef, group, tasmotacmnd = GetFieldDef(fielddef, fields='format, baseaddr, bits, bitshift, arraydef, group, tasmotacmnd') - # contains a integer list - if isinstance(datadef, list): - result = [] + # filter groups + if not IsFilterGroup(group): + return valuemapping + + # contains a integer list + if isinstance(arraydef, list) and len(arraydef) > 0: + valuemapping = [] offset = 0 - for i in range(0, datadef[0]): + for i in range(0, arraydef[0]): subfielddef = GetSubfieldDef(fielddef) length = GetFieldLength(subfielddef) if length != 0: - result.append(GetField(dobj, fieldname, subfielddef, raw=raw, addroffset=addroffset+offset)) + value = GetField(dobj, fieldname, subfielddef, raw=raw, addroffset=addroffset+offset) + valuemapping.append(value) offset += length - + # 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 + mapping_value = {} + # -> iterate through format + for name in format: + value = None + value = GetField(dobj, name, format[name], raw=raw, addroffset=addroffset) + if value is not None: + mapping_value[name] = value + # copy complete returned mapping + valuemapping = copy.deepcopy(mapping_value) # a simple value elif isinstance(format, (str, bool, int, float, long)): if GetFieldLength(fielddef) != 0: - result = struct.unpack_from(format, dobj, baseaddr+addroffset)[0] + valuemapping = 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 bits>0: - result &= (1< 127 - result = unicode(s, errors='ignore') + valuemapping = unicode(s, errors='ignore') - result = ConvertFieldValue(result, fielddef, read=True, raw=raw) + valuemapping = ReadWriteConverter(valuemapping, fielddef, read=True, raw=raw) else: exit(ExitCode.INTERNAL_ERROR, "Wrong mapping format definition: '{}'".format(format), typ=LogType.WARNING, doexit=not args.ignorewarning, line=inspect.getlineno(inspect.currentframe())) - return result + return valuemapping -def SetField(dobj, fieldname, fielddef, restore, raw=False, addroffset=0, filename=""): +def SetField(dobj, fieldname, fielddef, restore, addroffset=0, filename=""): """ Get field value from definition @@ -1581,48 +1851,50 @@ def SetField(dobj, fieldname, fielddef, restore, raw=False, addroffset=0, filena 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) + @param addroffset + use offset for baseaddr (used for recursive calls) + @param filename + related filename (for messages only) + + @return: + new decrypted binary config data """ - format, baseaddr, bits, bitshift, datadef, convert = GetFieldDef(fielddef) + format, baseaddr, bits, bitshift, arraydef, group, writeconverter = GetFieldDef(fielddef, fields='format, baseaddr, bits, bitshift, arraydef, group, writeconverter') + # cast unicode fieldname = str(fieldname) - if fieldname == 'raw' and not args.jsonrawkeys: + # filter groups + if not IsFilterGroup(group): return dobj # do not write readonly values - if isinstance(convert, (list,tuple)) and len(convert)>1 and convert[1]==None: + if writeconverter is False: if args.debug: - print >> sys.stderr, "SetField(): Readonly '{}' using '{}'/{}{} @{} skipped".format(fieldname, format, datadef, bits, hex(baseaddr+addroffset)) + print >> sys.stderr, "SetField(): Readonly '{}' using '{}'/{}{} @{} skipped".format(fieldname, format, arraydef, bits, hex(baseaddr+addroffset)) return dobj - # contains a list - if isinstance(datadef, list): + # contains a list + if isinstance(arraydef, list) and len(arraydef) > 0: 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]): + if len(restore) > arraydef[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=arraydef[0]), typ=LogType.WARNING, doexit=not args.ignorewarning, line=inspect.getlineno(inspect.currentframe())) + for i in range(0, arraydef[0]): subfielddef = GetSubfieldDef(fielddef) length = GetFieldLength(subfielddef) if length != 0: - if i>=len(restore): # restore data list may be shorter than definition + 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 + subrestore = restore[i] + dobj = SetField(dobj, fieldname, subfielddef, subrestore, addroffset=addroffset+offset, filename=filename) 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) + dobj = SetField(dobj, name, format[name], restore[name], addroffset=addroffset, filename=filename) # a simple value elif isinstance(format, (str, bool, int, float, long)): @@ -1633,76 +1905,82 @@ def SetField(dobj, fieldname, fielddef, restore, raw=False, addroffset=0, filena _min, _max = GetFieldMinMax(fielddef) value = _value = None skip = False - - # simple one value + + # simple char value if format[-1:] in ['c']: try: - value = ConvertFieldValue(restore.encode(STR_ENCODING)[0], fielddef, read=False, raw=raw) - except: - err = "valid range exceeding" + value = ReadWriteConverter(restore.encode(STR_ENCODING)[0], fielddef, read=False) + except Exception, e: + exit(e[0], e[1], typ=LogType.WARNING, line=inspect.getlineno(inspect.currentframe())) valid = False + # bool elif format[-1:] in ['?']: try: - value = ConvertFieldValue(bool(restore), fielddef, read=False, raw=raw) - except: - err = "valid range exceeding" + value = ReadWriteConverter(bool(restore), fielddef, read=False) + except Exception, e: + exit(e[0], e[1], typ=LogType.WARNING, line=inspect.getlineno(inspect.currentframe())) 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) + value = ReadWriteConverter(restore, fielddef, read=False) + if isinstance(value, (str, unicode)): + value = int(value, 0) + else: + value = int(value) + # bits + if bits != 0: + bitvalue = value + value = struct.unpack_from(format, dobj, baseaddr+addroffset)[0] + # validate restore value + valid = ValidateValue(bitvalue, fielddef) + if not valid: + err = "valid bit range exceeding" else: - value = int(value) - # bit value - if bits!=0: - value = struct.unpack_from(format, dobj, baseaddr+addroffset)[0] - bitvalue = int(restore) mask = (1<mask: + if bitvalue > mask: _min = 0 _max = mask _value = bitvalue valid = False - err = "valid bit range exceeding" else: - if bitshift>=0: + if bitshift >= 0: bitvalue <<= bitshift mask <<= bitshift else: bitvalue >>= abs(bitshift) mask >>= abs(bitshift) + v=value value &= (0xffffffff ^ mask) value |= bitvalue - # full size values - else: - _value = value - except: - valid = False - err = "valid range exceeding" + + # full size values + else: + # validate restore function + valid = ValidateValue(value, fielddef) + if not valid: + err = "valid range exceeding" + _value = value + # float elif format[-1:] in ['f','d']: try: - value = ConvertFieldValue(float(restore), fielddef, read=False, raw=raw) + value = ReadWriteConverter(float(restore), fielddef, read=False) except: - err = "valid range exceeding" valid = False + # string elif format[-1:] in ['s','p']: - try: - value = ConvertFieldValue(restore.encode(STR_ENCODING), fielddef, read=False, raw=raw) - err = "string length exceeding" - if value is not None: - # be aware 0 byte at end of string (str must be < max, not <= max) - _max -= 1 - valid = _min <= len(value) < _max - else: - skip = True - valid = True - except: - valid = False + value = ReadWriteConverter(restore.encode(STR_ENCODING), fielddef, read=False) + err = "string length exceeding" + if value is not None: + # be aware 0 byte at end of string (str must be < max, not <= max) + _max -= 1 + valid = _min <= len(value) < _max + else: + skip = True + valid = True if value is None and not skip: # None is an invalid value @@ -1719,7 +1997,7 @@ def SetField(dobj, fieldname, fielddef, restore, raw=False, addroffset=0, filena # copy value before possible change below _value = value - if isinstance(value, (str, unicode)): + if isinstance(_value, (str, unicode)): _value = "'{}'".format(_value) if valid: @@ -1729,7 +2007,7 @@ def SetField(dobj, fieldname, fielddef, restore, raw=False, addroffset=0, filena sbits=" {} bits shift {}".format(bits, bitshift) else: sbits = "" - print >> sys.stderr, "SetField(): Set '{}' using '{}'/{}{} @{} to {}".format(fieldname, format, datadef, sbits, hex(baseaddr+addroffset), _value) + print >> sys.stderr, "SetField(): Set '{}' using '{}'/{}{} @{} to {}".format(fieldname, format, arraydef, sbits, hex(baseaddr+addroffset), _value) if fieldname != 'cfg_crc': prevvalue = struct.unpack_from(format, dobj, baseaddr+addroffset)[0] struct.pack_into(format, dobj, baseaddr+addroffset, value) @@ -1743,22 +2021,84 @@ def SetField(dobj, fieldname, fielddef, restore, raw=False, addroffset=0, filena return dobj -def Bin2Mapping(decode_cfg, raw=True): +def SetCmnd(cmnds, fieldname, fielddef, valuemapping, mappedvalue, addroffset=0, idx=None): + """ + Get field value from definition + + @param cmnds: + Tasmota command mapping: { 'group': ['cmnd' <,'cmnd'...>] ... } + @param fieldname: + name of the field + @param fielddef: + see Settings desc above + @param valuemapping: + data mapping + @param mappedvalue + mappedvalue mapping with the new value(s) + @param addroffset + use offset for baseaddr (used for recursive calls) + @param idx + optional array index + + @return: + new Tasmota command mapping + """ + format, baseaddr, bits, bitshift, arraydef, group, tasmotacmnd, writeconverter = GetFieldDef(fielddef, fields='format, baseaddr, bits, bitshift, arraydef, group, tasmotacmnd, writeconverter') + + # cast unicode + fieldname = str(fieldname) + + # filter groups + if not IsFilterGroup(group): + return cmnds + + # contains a list + if isinstance(arraydef, list) and len(arraydef) > 0: + offset = 0 + if len(mappedvalue) > arraydef[0]: + exit(ExitCode.RESTORE_DATA_ERROR, "array '{sname}[{selem}]' exceeds max number of elements [{smax}]".format(sname=fieldname, selem=len(mappedvalue), smax=arraydef[0]), typ=LogType.WARNING, doexit=not args.ignorewarning, line=inspect.getlineno(inspect.currentframe())) + for i in range(0, arraydef[0]): + subfielddef = GetSubfieldDef(fielddef) + length = GetFieldLength(subfielddef) + if length != 0: + if i >= len(mappedvalue): # mappedvalue data list may be shorter than definition + break + subrestore = mappedvalue[i] + cmnds = SetCmnd(cmnds, fieldname, subfielddef, valuemapping, subrestore, addroffset=addroffset+offset, idx=i) + offset += length + + # contains a dict + elif isinstance(format, dict): + for name in format: # -> iterate through format + if name in mappedvalue: + cmnds = SetCmnd(cmnds, name, format[name], valuemapping, mappedvalue[name], addroffset=addroffset, idx=idx) + + # a simple value + elif isinstance(format, (str, bool, int, float, long)): + cmnd = CmndConverter(valuemapping, mappedvalue, idx, fielddef) + + if group is not None and cmnd is not None: + if group not in cmnds: + cmnds[group] = [] + cmnds[group].append(cmnd) + + return cmnds + + +def Bin2Mapping(decode_cfg): """ Decodes binary data stream into pyhton mappings dict @param decode_cfg: binary config data (decrypted) - @param raw: - decode raw values (True) or converted values (False) @return: - config data as mapping dictionary + valuemapping data as mapping dictionary """ if isinstance(decode_cfg, bytearray): decode_cfg = str(decode_cfg) - # get binary header to use + # get binary header and template to use version, size, setting = GetTemplateSetting(decode_cfg) # if we did not found a mathching setting @@ -1771,7 +2111,7 @@ def Bin2Mapping(decode_cfg, raw=True): # check size if exists if 'cfg_size' in setting: cfg_size = GetField(decode_cfg, 'cfg_size', setting['cfg_size'], raw=True) - # read size should be same as definied in template + # read size should be same as definied in setting if cfg_size > size: # may be processed exit(ExitCode.DATA_SIZE_MISMATCH, "Number of bytes read does ot match - read {}, expected {} byte".format(cfg_size, size), typ=LogType.ERROR,line=inspect.getlineno(inspect.currentframe())) @@ -1787,41 +2127,39 @@ def Bin2Mapping(decode_cfg, raw=True): 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(decode_cfg, None, (setting,None,None), raw=raw) + # get valuemapping + valuemapping = GetField(decode_cfg, None, (setting,0,(None, None, (INTERNAL, None)))) # add header info timestamp = datetime.now() - 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, - }, - 'template': { - 'version': hex(version), - 'crc': hex(cfg_crc), - }, - 'data': { - 'crc': hex(GetSettingsCrc(decode_cfg)), - 'size': len(decode_cfg), - }, - 'script': { - 'name': os.path.basename(__file__), - 'version': VER, - }, - 'os': (platform.machine(), platform.system(), platform.release(), platform.version(), platform.platform()), - 'python': platform.python_version(), - } + valuemapping['header'] = { 'timestamp':timestamp.strftime("%Y-%m-%d %H:%M:%S"), + 'format': { + 'jsonindent': args.jsonindent, + 'jsoncompact': args.jsoncompact, + 'jsonsort': args.jsonsort, + 'jsonhidepw': args.jsonhidepw, + }, + 'template': { + 'version': hex(version), + 'crc': hex(cfg_crc), + }, + 'data': { + 'crc': hex(GetSettingsCrc(decode_cfg)), + 'size': len(decode_cfg), + }, + 'script': { + 'name': os.path.basename(__file__), + 'version': VER, + }, + 'os': (platform.machine(), platform.system(), platform.release(), platform.version(), platform.platform()), + 'python': platform.python_version(), + } if 'cfg_crc' in setting: - config['header']['template'].update({'size': cfg_size}) + valuemapping['header']['template'].update({'size': cfg_size}) if 'version' in setting: - config['header']['data'].update({'version': hex(cfg_version)}) + valuemapping['header']['data'].update({'version': hex(cfg_version)}) - return config + return valuemapping def Mapping2Bin(decode_cfg, jsonconfig, filename=""): @@ -1836,7 +2174,7 @@ def Mapping2Bin(decode_cfg, jsonconfig, filename=""): name of the restore file (for error output only) @return: - changed binary config data (decrypted) + changed binary config data (decrypted) or None on error """ if isinstance(decode_cfg, str): decode_cfg = bytearray(decode_cfg) @@ -1845,41 +2183,73 @@ def Mapping2Bin(decode_cfg, jsonconfig, filename=""): # get binary header data to use the correct version template from device version, size, setting = GetTemplateSetting(decode_cfg) + # make empty binarray array _buffer = bytearray() + # add data _buffer.extend(decode_cfg) if setting 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) + SetField(_buffer, name, setting[name], jsonconfig[name], 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) + if 'cfg_crc' in setting: + 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 + return None -def Backup(backupfile, backupfileformat, encode_cfg, decode_cfg, configuration): +def Mapping2Cmnd(decode_cfg, valuemapping, filename=""): + """ + Encodes mapping data into Tasmota command mapping + + @param decode_cfg: + binary config data (decrypted) + @param valuemapping: + data mapping + @param filename: + name of the restore file (for error output only) + + @return: + Tasmota command mapping {group: [cmnd <,cmnd <,...>>]} + """ + if isinstance(decode_cfg, str): + decode_cfg = bytearray(decode_cfg) + + # get binary header data to use the correct version template from device + version, size, setting = GetTemplateSetting(decode_cfg) + + cmnds = {} + + if setting is not None: + # iterate through restore data mapping + for name in valuemapping: + # key must exist in both dict + if name in setting: + cmnds = SetCmnd(cmnds, name, setting[name], valuemapping, valuemapping[name], addroffset=0) + 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) + + return cmnds + + else: + exit(ExitCode.UNSUPPORTED_VERSION,"File '{}', Tasmota configuration version 0x{:x} not supported".format(filename, version), typ=LogType.WARNING, doexit=not args.ignorewarning) + + return None + + +def Backup(backupfile, backupfileformat, encode_cfg, decode_cfg, configmapping): """ Create backup file @@ -1891,27 +2261,38 @@ def Backup(backupfile, backupfileformat, encode_cfg, decode_cfg, configuration): binary config data (encrypted) @param decode_cfg: binary config data (decrypted) - @param configuration: + @param configmapping: 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 + 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 fileformat = "" + # Tasmota format + if backupfileformat.lower() == FileType.DMP.lower(): + fileformat = "Tasmota" + backup_filename = MakeFilename(backupfile, FileType.DMP, configmapping) + if args.verbose: + message("Writing backup file '{}' ({} format)".format(backup_filename, fileformat), typ=LogType.INFO) + try: + backupfp = open(backup_filename, "wb") + backupfp.write(encode_cfg) + except Exception, e: + exit(e[0], "'{}' {}".format(backup_filename, e[1]),line=inspect.getlineno(inspect.currentframe())) + finally: + backupfp.close() + # binary format - if backupfileformat.lower() == FileType.BIN.lower(): + elif backupfileformat.lower() == FileType.BIN.lower(): fileformat = "binary" - backup_filename = MakeFilename(backupfile, FileType.BIN, configuration) + backup_filename = MakeFilename(backupfile, FileType.BIN, configmapping) if args.verbose: message("Writing backup file '{}' ({} format)".format(backup_filename, fileformat), typ=LogType.INFO) try: @@ -1924,33 +2305,20 @@ def Backup(backupfile, backupfileformat, encode_cfg, decode_cfg, configuration): finally: backupfp.close() - # Tasmota format - if backupfileformat.lower() == FileType.DMP.lower(): - fileformat = "Tasmota" - backup_filename = MakeFilename(backupfile, FileType.DMP, configuration) + # JSON format + elif backupfileformat.lower() == FileType.JSON.lower(): + fileformat = "JSON" + backup_filename = MakeFilename(backupfile, FileType.JSON, configmapping) if args.verbose: message("Writing backup file '{}' ({} format)".format(backup_filename, fileformat), typ=LogType.INFO) try: - backupfp = open(backup_filename, "wb") - backupfp.write(encode_cfg) + backupfp = open(backup_filename, "w") + json.dump(configmapping, backupfp, sort_keys=args.jsonsort, indent=None if args.jsonindent < 0 else args.jsonindent, separators=(',', ':') if args.jsoncompact else (', ', ': ') ) except Exception, e: exit(e[0], "'{}' {}".format(backup_filename, e[1]),line=inspect.getlineno(inspect.currentframe())) finally: backupfp.close() - # JSON format - elif backupfileformat.lower() == FileType.JSON.lower(): - fileformat = "JSON" - backup_filename = MakeFilename(backupfile, FileType.JSON, configuration) - if args.verbose: - message("Writing backup file '{}' ({} format)".format(backup_filename, fileformat), typ=LogType.INFO) - try: - backupfp = open(backup_filename, "w") - json.dump(configuration, backupfp, sort_keys=args.jsonsort, indent=None if args.jsonindent<0 else args.jsonindent, separators=(',', ':') if args.jsoncompact else (', ', ': ') ) - except Exception, e: - exit(e[0], "'{}' {}".format(backup_filename, e[1]),line=inspect.getlineno(inspect.currentframe())) - finally: - backupfp.close() if args.verbose: srctype = 'device' src = args.device @@ -1960,7 +2328,7 @@ def Backup(backupfile, backupfileformat, encode_cfg, decode_cfg, configuration): message("Backup successful from {} '{}' to file '{}' ({} format)".format(srctype, src, backup_filename, fileformat), typ=LogType.INFO) -def Restore(restorefile, encode_cfg, decode_cfg, configuration): +def Restore(restorefile, encode_cfg, decode_cfg, configmapping): """ Restore from file @@ -1968,13 +2336,13 @@ def Restore(restorefile, encode_cfg, decode_cfg, configuration): binary config data (encrypted) @param decode_cfg: binary config data (decrypted) - @param configuration: + @param configmapping: config data mapppings """ new_encode_cfg = None - restorefilename = MakeFilename(restorefile, None, configuration) + restorefilename = MakeFilename(restorefile, None, configmapping) filetype = GetFileType(restorefilename) if filetype == FileType.DMP: @@ -2025,7 +2393,7 @@ def Restore(restorefile, encode_cfg, decode_cfg, configuration): exit(ExitCode.FILE_READ_ERROR, "File '{}' unknown error".format(restorefilename),line=inspect.getlineno(inspect.currentframe())) if new_encode_cfg is not None: - if new_encode_cfg != encode_cfg or args.ignorewarning: + if args.forcerestore or new_encode_cfg != encode_cfg: # write config direct to device via http if args.device is not None: if args.verbose: @@ -2058,6 +2426,36 @@ def Restore(restorefile, encode_cfg, decode_cfg, configuration): message("Configuration data leaving unchanged", typ=LogType.INFO) +def OutputTasmotaCmnds(tasmotacmnds): + """ + Print Tasmota command mapping + + @param tasmotacmnds: + Tasmota command mapping {group: [cmnd <,cmnd <,...>>]} + """ + def OutputTasmotaSubCmnds(cmnds): + if args.cmndsort: + for cmnd in sorted(cmnds, key = lambda cmnd:[int(c) if c.isdigit() else c for c in re.split('(\d+)', cmnd)]): + print "{}{}".format(" "*args.cmndindent, cmnd) + else: + for cmnd in cmnds: + print "{}{}".format(" "*args.cmndindent, cmnd) + + if args.cmndgroup: + for group in Groups: + if group in tasmotacmnds: + cmnds = tasmotacmnds[group] + print + print "# {}:".format(group) + OutputTasmotaSubCmnds(cmnds) + + else: + cmnds = [] + for group in Groups: + if group in tasmotacmnds: + cmnds.extend(tasmotacmnds[group]) + OutputTasmotaSubCmnds(cmnds) + def ParseArgs(): """ Program argument parser @@ -2098,7 +2496,7 @@ def ParseArgs(): default=DEFAULTS['source']['password'], help="host HTTP access password (default: {})".format(DEFAULTS['source']['password'])) - backup = parser.add_argument_group('Backup/Restore', 'Backup/Restore configuration file specification') + backup = parser.add_argument_group('Backup/Restore', 'Backup & restore specification') backup.add_argument('-i', '--restore-file', metavar='', dest='restorefile', @@ -2109,11 +2507,11 @@ def ParseArgs(): dest='backupfile', default=DEFAULTS['backup']['backupfile'], help="file to backup configuration to (default: {}). Replacements: @v=firmware version, @f=device friendly name, @h=device hostname".format(DEFAULTS['backup']['backupfile'])) - output_file_formats = ['json', 'bin', 'dmp'] - backup.add_argument('-F', '--backup-type', - metavar='|'.join(output_file_formats), + backup_file_formats = ['json', 'bin', 'dmp'] + backup.add_argument('-t', '--backup-type', + metavar='|'.join(backup_file_formats), dest='backupfileformat', - choices=output_file_formats, + choices=backup_file_formats, default=DEFAULTS['backup']['backupfileformat'], help="backup filetype (default: '{}')".format(DEFAULTS['backup']['backupfileformat']) ) backup.add_argument('-E', '--extension', @@ -2126,8 +2524,13 @@ def ParseArgs(): action='store_false', default=DEFAULTS['backup']['extension'], help="do not append filetype extension, use -i and -o filename as passed{}".format(' (default)' if not DEFAULTS['backup']['extension'] else '') ) + backup.add_argument('-F', '--force-restore', + dest='forcerestore', + action='store_true', + default=DEFAULTS['backup']['forcerestore'], + help="force restore even configuration is identical{}".format(' (default)' if DEFAULTS['backup']['forcerestore'] else '') ) - jsonformat = parser.add_argument_group('JSON', 'JSON backup format specification') + jsonformat = parser.add_argument_group('JSON output', 'JSON format specification') jsonformat.add_argument('--json-indent', metavar='', dest='jsonindent', @@ -2151,40 +2554,82 @@ def ParseArgs(): default=DEFAULTS['jsonformat']['jsonsort'], help=configargparse.SUPPRESS) #"do not sort json keywords{}".format(' (default)' if not DEFAULTS['jsonformat']['jsonsort'] else '') ) - jsonformat.add_argument('--json-raw-values', - dest='jsonrawvalues', - action='store_true', - default=DEFAULTS['jsonformat']['jsonrawvalues'], - help=configargparse.SUPPRESS) #"output raw values{}".format(' (default)' if DEFAULTS['jsonformat']['jsonrawvalues'] else '') ) - jsonformat.add_argument('--json-convert-values', - dest='jsonrawvalues', - action='store_false', - default=DEFAULTS['jsonformat']['jsonrawvalues'], - help=configargparse.SUPPRESS) #"output converted, human readable values{}".format(' (default)' if not DEFAULTS['jsonformat']['jsonrawvalues'] else '') ) - - jsonformat.add_argument('--json-raw-keys', - dest='jsonrawkeys', - action='store_true', - default=DEFAULTS['jsonformat']['jsonrawkeys'], - help=configargparse.SUPPRESS) #"output bitfield raw keys{}".format(' (default)' if DEFAULTS['jsonformat']['jsonrawkeys'] else '') ) - jsonformat.add_argument('--json-no-raw-keys', - dest='jsonrawkeys', - action='store_false', - default=DEFAULTS['jsonformat']['jsonrawkeys'], - help=configargparse.SUPPRESS) #"do not output bitfield raw keys{}".format(' (default)' if not DEFAULTS['jsonformat']['jsonrawkeys'] else '') ) - jsonformat.add_argument('--json-hide-pw', dest='jsonhidepw', action='store_true', default=DEFAULTS['jsonformat']['jsonhidepw'], help="hide passwords{}".format(' (default)' if DEFAULTS['jsonformat']['jsonhidepw'] else '') ) - jsonformat.add_argument('--json-unhide-pw', + jsonformat.add_argument('--json-show-pw', '--json-unhide-pw', dest='jsonhidepw', action='store_false', default=DEFAULTS['jsonformat']['jsonhidepw'], help="unhide passwords{}".format(' (default)' if not DEFAULTS['jsonformat']['jsonhidepw'] else '') ) - info = parser.add_argument_group('Info','additional information') + cmndformat = parser.add_argument_group('Tasmota command output', 'Tasmota command output format specification') + cmndformat.add_argument('--cmnd-indent', + metavar='', + dest='cmndindent', + type=int, + default=DEFAULTS['cmndformat']['cmndindent'], + help="Tasmota command grouping indent level (default: '{}'). 0 disables indent".format(DEFAULTS['cmndformat']['cmndindent']) ) + cmndformat.add_argument('--cmnd-groups', + dest='cmndgroup', + action='store_true', + default=DEFAULTS['cmndformat']['cmndgroup'], + help="group Tasmota commands{}".format(' (default)' if DEFAULTS['cmndformat']['cmndgroup'] else '') ) + cmndformat.add_argument('--cmnd-nogroups', + dest='cmndgroup', + action='store_false', + default=DEFAULTS['cmndformat']['cmndgroup'], + help="leave Tasmota commands ungrouped{}".format(' (default)' if not DEFAULTS['cmndformat']['cmndgroup'] else '') ) + cmndformat.add_argument('--cmnd-sort', + dest='cmndsort', + action='store_true', + default=DEFAULTS['cmndformat']['cmndsort'], + help="sort Tasmota commands{}".format(' (default)' if DEFAULTS['cmndformat']['cmndsort'] else '') ) + cmndformat.add_argument('--cmnd-unsort', + dest='cmndsort', + action='store_false', + default=DEFAULTS['cmndformat']['cmndsort'], + help="leave Tasmota commands unsorted{}".format(' (default)' if not DEFAULTS['cmndformat']['cmndsort'] else '') ) + + common = parser.add_argument_group('Common', 'Optional arguments') + common.add_argument('-c', '--config', + metavar='', + dest='configfile', + default=DEFAULTS['common']['configfile'], + is_config_file=True, + help="program config file - can be used to set default command args (default: {})".format(DEFAULTS['common']['configfile']) ) + + common.add_argument('-S', '--output', + dest='output', + action='store_true', + default=DEFAULTS['common']['output'], + help="display output regardsless of backup/restore usage{}".format(" (default)" if DEFAULTS['common']['output'] else " (default do not output on backup or restore usage)") ) + output_formats = ['json', 'cmnd','command'] + common.add_argument('-T', '--output-format', + metavar='|'.join(output_formats), + dest='outputformat', + choices=output_formats, + default=DEFAULTS['common']['outputformat'], + help="display output format (default: '{}')".format(DEFAULTS['common']['outputformat']) ) + groups = GetGroupList(Settings[0][2]) + if '*' in groups: + groups.remove('*') + common.add_argument('-g', '--group', + dest='filter', + choices=groups, + nargs='+', + default=DEFAULTS['common']['filter'], + help="limit data processing to command groups (default {})".format("no filter" if DEFAULTS['common']['filter'] == None else DEFAULTS['common']['filter']) ) + common.add_argument('--ignore-warnings', + dest='ignorewarning', + action='store_true', + default=DEFAULTS['common']['ignorewarning'], + help="do not exit on warnings{}. Not recommended, used by your own responsibility!".format(' (default)' if DEFAULTS['common']['ignorewarning'] else '') ) + + + info = parser.add_argument_group('Info','Extra information') info.add_argument('-D', '--debug', dest='debug', action='store_true', @@ -2204,19 +2649,6 @@ def ParseArgs(): action='version', version=PROG) - # optional arguments - parser.add_argument('-c', '--config', - metavar='', - dest='configfile', - default=DEFAULTS['DEFAULT']['configfile'], - is_config_file=True, - help="program config file - can be used to set default command args (default: {})".format(DEFAULTS['DEFAULT']['configfile']) ) - parser.add_argument('--ignore-warnings', - dest='ignorewarning', - action='store_true', - default=DEFAULTS['DEFAULT']['ignorewarning'], - help="do not exit on warnings{}. Not recommended, used by your own responsibility!".format(' (default)' if DEFAULTS['DEFAULT']['ignorewarning'] else '') ) - args = parser.parse_args() if args.debug: @@ -2266,23 +2698,28 @@ if __name__ == "__main__": decode_cfg = DecryptEncrypt(encode_cfg) # decode into mappings dictionary - configuration = Bin2Mapping(decode_cfg, args.jsonrawvalues) - if args.verbose and 'version' in configuration: - if args.tasmotafile is not None: - message("File '{}' contains data for Tasmota v{}".format(args.tasmotafile, GetVersionStr(configuration['version'])),typ=LogType.INFO) - else: - message("Device '{}' runs Tasmota v{}".format(args.device,GetVersionStr(configuration['version'])),typ=LogType.INFO) + configmapping = Bin2Mapping(decode_cfg) + if args.verbose and 'version' in configmapping: + message("{} '{}' is using version {}".format('File' if args.tasmotafile is not None else 'Device', + args.tasmotafile if args.tasmotafile is not None else args.device, + GetVersionStr(configmapping['version'])), + typ=LogType.INFO) # backup to file if args.backupfile is not None: - Backup(args.backupfile, args.backupfileformat, encode_cfg, decode_cfg, configuration) + Backup(args.backupfile, args.backupfileformat, encode_cfg, decode_cfg, configmapping) # restore from file if args.restorefile is not None: - Restore(args.restorefile, encode_cfg, decode_cfg, configuration) + Restore(args.restorefile, encode_cfg, decode_cfg, configmapping) # json screen output - if args.backupfile is None and args.restorefile is None: - print json.dumps(configuration, sort_keys=args.jsonsort, indent=None if args.jsonindent<0 else args.jsonindent, separators=(',', ':') if args.jsoncompact else (', ', ': ') ) + if (args.backupfile is None and args.restorefile is None) or args.output: + if args.outputformat == 'json': + print json.dumps(configmapping, sort_keys=args.jsonsort, indent=None if args.jsonindent<0 else args.jsonindent, separators=(',', ':') if args.jsoncompact else (', ', ': ') ) + if args.outputformat == 'cmnd' or args.outputformat == 'command': + tasmotacmnds = Mapping2Cmnd(decode_cfg, configmapping) + OutputTasmotaCmnds(tasmotacmnds) + sys.exit(exitcode)