Repository: bpicode/fritzctl
Pull request #141 (2018-03-18 00:01:25)

Issue #139: json output for thermostats and switches
`fritzctl list thermostats` as well as `fritzctl list switches`
now have an option `--output=json`, which serializes the data
into json format and prints it to stdout. An example is:
```js
{
  "numberOfItems": 1,
  "devices": [
    {
      "id": "44363 2777777",
      "internalId": "12",
      "name": "HKR_1",
      "properties": {
        "vendor": {
          "manufacturer": "AVM",
          "productName": "Comet DECT",
          "firmwareVersion": "03.54"
        },
        "lock": {
          "hwLock": "LOCKED",
          "swLock": "UNLOCKED"
        },
        "warnings": [
          "Battery is running on low capacity"
        ]
      },
      "measurements": {
        "temperature": "20.5"
      },
      "state": {
        "connected": true,
        "temperatureControl": {
          "goal": "OFF",
          "saving": "18",
          "comfort": "20",
          "nextChange": {
            "at": "2017-09-30T23:00:00+02:00",
            "goal": "20"
          }
        }
      }
    }
  ]
}
```
Addresses #139  

codecov[bot]: # [Codecov](https://codecov.io/gh/bpicode/fritzctl/pull/141?src=pr&el=h1) Report
> Merging [#141](https://codecov.io/gh/bpicode/fritzctl/pull/141?src=pr&el=desc) into [master](https://codecov.io/gh/bpicode/fritzctl/commit/5366993f289054d12964c4936acf236901687ca7?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/bpicode/fritzctl/pull/141/graphs/tree.svg?height=150&width=650&token=BE6YV2nOnc&src=pr)](https://codecov.io/gh/bpicode/fritzctl/pull/141?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #141    +/-   ##
======================================
  Coverage     100%   100%            
======================================
  Files          78     80     +2     
  Lines        1990   2097   +107     
======================================
+ Hits         1990   2097   +107
```


| [Impacted Files](https://codecov.io/gh/bpicode/fritzctl/pull/141?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [fritz/powermeter.go](https://codecov.io/gh/bpicode/fritzctl/pull/141/diff?src=pr&el=tree#diff-ZnJpdHovcG93ZXJtZXRlci5nbw==) | `100% <ø> (ø)` | :arrow_up: |
| [fritz/thermostat.go](https://codecov.io/gh/bpicode/fritzctl/pull/141/diff?src=pr&el=tree#diff-ZnJpdHovdGhlcm1vc3RhdC5nbw==) | `100% <ø> (ø)` | :arrow_up: |
| [cmd/list\_thermostats.go](https://codecov.io/gh/bpicode/fritzctl/pull/141/diff?src=pr&el=tree#diff-Y21kL2xpc3RfdGhlcm1vc3RhdHMuZ28=) | `100% <100%> (ø)` | :arrow_up: |
| [cmd/list\_switches.go](https://codecov.io/gh/bpicode/fritzctl/pull/141/diff?src=pr&el=tree#diff-Y21kL2xpc3Rfc3dpdGNoZXMuZ28=) | `100% <100%> (ø)` | :arrow_up: |
| [cmd/jsonapi/converter.go](https://codecov.io/gh/bpicode/fritzctl/pull/141/diff?src=pr&el=tree#diff-Y21kL2pzb25hcGkvY29udmVydGVyLmdv) | `100% <100%> (ø)` | |
| [cmd/printer/print.go](https://codecov.io/gh/bpicode/fritzctl/pull/141/diff?src=pr&el=tree#diff-Y21kL3ByaW50ZXIvcHJpbnQuZ28=) | `100% <100%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/bpicode/fritzctl/pull/141?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/bpicode/fritzctl/pull/141?src=pr&el=footer). Last update [5366993...38d8379](https://codecov.io/gh/bpicode/fritzctl/pull/141?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/NOTICE b/NOTICE
deleted file mode 100644
index c318a63..0000000
--- a/NOTICE
+++ /dev/null
@@ -1,39 +0,0 @@
-# 3rd-Party Software License Notice
-
-This software includes the following software and licenses:
-
-========================================================================
-Apache License 2.0 (Apache-2.0)
-========================================================================
-
-The following software have components provided under the terms of this license:
-
-- cobra (from https://github.com/spf13/cobra)
-
-========================================================================
-MIT License (Expat)
-========================================================================
-
-The following software have components provided under the terms of this license:
-
-- go-md2man (from https://github.com/cpuguy83/go-md2man)
-- color (from https://github.com/fatih/color)
-- go-colorable (from https://github.com/mattn/go-colorable)
-- go-isatty (from https://github.com/mattn/go-isatty)
-
-========================================================================
-BSD 3-Clause License (Revised)
-========================================================================
-
-The following software have components provided under the terms of this license:
-
-- httprouter (from https://github.com/julienschmidt/httprouter)
-- pflag (from https://github.com/spf13/pflag)
-
-========================================================================
-BSD 2-Clause License (FreeBSD/Simplified)
-========================================================================
-
-The following software have components provided under the terms of this license:
-
-- blackfriday (from https://github.com/russross/blackfriday)
diff --git a/README.md b/README.md
index d88636e..98310b4 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ It should work out-of-the-box with most FRITZ!Boxes running a recent FRITZ!OS ve
 ## Code [![Go Report Card](https://goreportcard.com/badge/github.com/bpicode/fritzctl)](https://goreportcard.com/report/github.com/bpicode/fritzctl) [![codecov](https://codecov.io/gh/bpicode/fritzctl/branch/master/graph/badge.svg)](https://codecov.io/gh/bpicode/fritzctl) [![codebeat badge](https://codebeat.co/badges/605cf539-21dd-4a60-a892-e0d6da3021fe)](https://codebeat.co/projects/github-com-bpicode-fritzctl) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/356d5568f61e40c3ad430786f766231e)](https://www.codacy.com/app/bjoern.pirnay/fritzctl?utm_source=github.com&utm_medium=referral&utm_content=bpicode/fritzctl&utm_campaign=badger) [![BCH compliance](https://bettercodehub.com/edge/badge/bpicode/fritzctl?branch=master)](https://bettercodehub.com/results/bpicode/fritzctl) [![Maintainability](https://api.codeclimate.com/v1/badges/0dbf66a5ad3c5e059656/maintainability)](https://codeclimate.com/github/bpicode/fritzctl/maintainability)
 
 ## Releases
-*   [![release](https://img.shields.io/github/release/bpicode/fritzctl.svg)](https://github.com/bpicode/fritzctl/releases/latest) [![downloads](https://img.shields.io/github/downloads/bpicode/fritzctl/total.svg)](https://github.com/bpicode/fritzctl/releases/latest) github release
+*   [![release](http://github-release-version.herokuapp.com/github/bpicode/fritzctl/release.svg?style=flat)](https://github.com/bpicode/fritzctl/releases/latest) [![downloads](https://img.shields.io/github/downloads/bpicode/fritzctl/total.svg)](https://github.com/bpicode/fritzctl/releases/latest) github release
 *   [![Download .deb](https://api.bintray.com/packages/bpicode/fritzctl_deb/fritzctl/images/download.svg)](https://bintray.com/bpicode/fritzctl_deb/fritzctl/_latestVersion)
     .deb packages
 *   [![Download .rpm](https://api.bintray.com/packages/bpicode/fritzctl_rpm/fritzctl/images/download.svg)](https://bintray.com/bpicode/fritzctl_rpm/fritzctl/_latestVersion)
@@ -76,7 +76,7 @@ sudo zypper in fritzctl
 
 ### MacOS
 
-Install using homebrew
+Intall using homebrew
 
 ```sh
 brew install bpicode/tap/fritzctl
diff --git a/cmd/jsonapi/converter.go b/cmd/jsonapi/converter.go
new file mode 100644
index 0000000..bca9f66
--- /dev/null
+++ b/cmd/jsonapi/converter.go
@@ -0,0 +1,142 @@
+package jsonapi
+
+import (
+	"strconv"
+	"time"
+
+	"github.com/bpicode/fritzctl/fritz"
+)
+
+// Mapper maps the XML to JSON model.
+type Mapper interface {
+	Convert([]fritz.Device) DeviceList
+}
+
+// NewMapper instantiates a Mapper.
+func NewMapper() Mapper {
+	return &mapper{}
+}
+
+type mapper struct {
+}
+
+// Convert translates a slice of Devices into a ThermostatList
+func (m *mapper) Convert(ds []fritz.Device) DeviceList {
+	l := DeviceList{}
+	l.NumberOfItems = len(ds)
+	for _, d := range ds {
+		l.Devices = append(l.Devices, m.convertOne(d))
+	}
+	return l
+}
+
+func (m *mapper) convertOne(src fritz.Device) Device {
+	target := Device{}
+	m.mapIdentifiers(&target, &src)
+	m.mapProperties(&target, &src)
+	m.mapMeasurements(&target, &src)
+	m.mapState(&target, &src)
+	return target
+}
+
+func (m *mapper) mapIdentifiers(target *Device, src *fritz.Device) {
+	target.ID = src.Identifier
+	target.InternalID = src.ID
+	target.Name = src.Name
+}
+
+func (m *mapper) mapProperties(target *Device, src *fritz.Device) {
+	props := &Properties{}
+	mapVendor(props, src)
+	mapLock(props, src)
+	mapWarnings(props, src)
+	target.Properties = props
+}
+
+func mapWarnings(target *Properties, src *fritz.Device) {
+	if src.Thermostat.BatteryLow != "1" {
+		target.Warnings = append(target.Warnings, "Battery is running on low capacity")
+	}
+	if fritz.HkrErrorDescriptions[src.Thermostat.ErrorCode] != "" {
+		target.Warnings = append(target.Warnings, fritz.HkrErrorDescriptions[src.Thermostat.ErrorCode])
+	}
+}
+
+func mapVendor(target *Properties, src *fritz.Device) {
+	target.Vendor = &Vendor{
+		Manufacturer:    src.Manufacturer,
+		ProductName:     src.Productname,
+		FirmwareVersion: src.Fwversion,
+	}
+}
+
+var lockStateLookUp = map[string]string{
+	"0": "UNLOCKED",
+	"1": "LOCKED",
+}
+
+func mapLock(target *Properties, src *fritz.Device) {
+
+	if src.Switch.Lock != "" || src.Switch.DeviceLock != "" {
+		target.Lock = &Lock{
+			HwLock: lockStateLookUp[src.Switch.DeviceLock],
+			SwLock: lockStateLookUp[src.Switch.Lock],
+		}
+	}
+	if src.Thermostat.Lock != "" || src.Thermostat.DeviceLock != "" {
+		target.Lock = &Lock{
+			HwLock: lockStateLookUp[src.Thermostat.DeviceLock],
+			SwLock: lockStateLookUp[src.Thermostat.Lock],
+		}
+	}
+}
+
+func (m *mapper) mapMeasurements(target *Device, src *fritz.Device) {
+	meas := &Measurements{}
+	if src.Temperature.Celsius != "" {
+		meas.Temperature = src.Temperature.FmtCelsius()
+	}
+	if src.Powermeter.Power != "" {
+		meas.PowerConsumption = src.Powermeter.FmtPowerW()
+	}
+	if src.Powermeter.Energy != "" {
+		meas.EnergyConsumption = src.Powermeter.FmtEnergyWh()
+	}
+	target.Measurements = meas
+}
+
+var switchStateLookup = map[string]string{
+	"0": "OFF",
+	"1": "ON",
+}
+
+func (m *mapper) mapState(target *Device, src *fritz.Device) {
+	st := &State{}
+	st.Connected = src.Present == 1
+	st.Switch = switchStateLookup[src.Switch.State]
+	if src.IsThermostat() {
+		m.mapThermostat(st, src)
+	}
+	target.State = st
+}
+
+func (m *mapper) mapThermostat(target *State, src *fritz.Device) {
+	tc := &TemperatureControl{}
+	tc.Goal = src.Thermostat.FmtGoalTemperature()
+	tc.Saving = src.Thermostat.FmtSavingTemperature()
+	tc.Comfort = src.Thermostat.FmtComfortTemperature()
+	if src.Thermostat.NextChange.Goal != "" {
+		m.mapNextChange(tc, src)
+	}
+	target.TemperatureControl = tc
+}
+
+func (m *mapper) mapNextChange(target *TemperatureControl, src *fritz.Device) {
+	nc := &NextChange{}
+	nc.Goal = src.Thermostat.NextChange.FmtGoalTemperature()
+	t, err := strconv.ParseInt(src.Thermostat.NextChange.TimeStamp, 10, 64)
+	if err == nil {
+		nc.At = time.Unix(t, 0).Format(time.RFC3339)
+	}
+	target.NextChange = nc
+}
diff --git a/cmd/jsonapi/model.go b/cmd/jsonapi/model.go
new file mode 100644
index 0000000..48320c3
--- /dev/null
+++ b/cmd/jsonapi/model.go
@@ -0,0 +1,69 @@
+package jsonapi
+
+// codebeat:disable[TOO_MANY_IVARS]
+
+// DeviceList wraps a collection of devices.
+type DeviceList struct {
+	NumberOfItems int      `json:"numberOfItems"` // Number of items.
+	Devices       []Device `json:"devices"`       // The temperature to switch to. Same unit convention as in Thermostat.Measured.
+}
+
+// Device can represent any AHA managed hardware.
+type Device struct {
+	ID           string        `json:"id,omitempty"`           // A unique ID like AIN, MAC address, etc.
+	InternalID   string        `json:"internalId,omitempty"`   // Internal device ID of the FRITZ!Box.
+	Name         string        `json:"name,omitempty"`         // The name of the device. Can be assigned in the web gui of the FRITZ!Box.
+	Properties   *Properties   `json:"properties,omitempty"`   // Static or pseudo-static properties.
+	Measurements *Measurements `json:"measurements,omitempty"` // Sensor data.
+	State        *State        `json:"state,omitempty"`        // State of the home.
+}
+
+// Properties refers to static or rarely changing information on the device.
+type Properties struct {
+	Vendor   *Vendor  `json:"vendor,omitempty"`   // Vendor data.
+	Lock     *Lock    `json:"lock,omitempty"`     // Lock state.
+	Warnings []string `json:"warnings,omitempty"` // Error messages, warnings etc.
+}
+
+// Vendor contains device metadata, see embedded fields.
+type Vendor struct {
+	Manufacturer    string `json:"manufacturer"`    // Manufacturer of the device.
+	ProductName     string `json:"productName"`     // Name of the product, empty for unknown or undefined devices.
+	FirmwareVersion string `json:"firmwareVersion"` // Firmware version of the device.
+}
+
+// Lock contains the lock information of a device. A "lock" prevents manual changes to the device.
+type Lock struct {
+	HwLock string `json:"hwLock,omitempty"` // Lock set directly on the device.
+	SwLock string `json:"swLock,omitempty"` // Device locked by FRITZ!Box.
+}
+
+// Measurements indicate runtime data obtained by device senors.
+type Measurements struct {
+	Temperature       string `json:"temperature,omitempty"`       // Temperature measured in °C.
+	PowerConsumption  string `json:"powerConsumption,omitempty"`  // Current power in W.
+	EnergyConsumption string `json:"energyConsumption,omitempty"` // Absolute energy consumption in Wh since the device started operating.
+}
+
+// State contains the core domain of the device, e.g. "is the switch on?", "what is the room temperature supposed to be?".
+type State struct {
+	Connected          bool                `json:"connected"`                    // Device connected?
+	Switch             string              `json:"switch,omitempty"`             // "ON" or "OFF" or "" (if it does not apply).
+	TemperatureControl *TemperatureControl `json:"temperatureControl,omitempty"` // Applies to thermostats.
+}
+
+// TemperatureControl applies to AHA devices capable of adjusting room temperature.
+type TemperatureControl struct {
+	Goal       string      `json:"goal,omitempty"`       // Desired temperature, user controlled.
+	Saving     string      `json:"saving,omitempty"`     // Energy saving temperature.
+	Comfort    string      `json:"comfort,omitempty"`    // Comfortable temperature.
+	NextChange *NextChange `json:"nextChange,omitempty"` // Comfortable temperature.
+}
+
+// NextChange indicates the upcoming scheduled temperature change.
+type NextChange struct {
+	At   string `json:"at"`   // Timestamp  when the next temperature switch is scheduled. Formatted as RFC3339.
+	Goal string `json:"goal"` // The temperature to switch to.
+}
+
+// codebeat:enable[TOO_MANY_IVARS]
diff --git a/cmd/list_switches.go b/cmd/list_switches.go
index 211225a..91204fd 100644
--- a/cmd/list_switches.go
+++ b/cmd/list_switches.go
@@ -4,6 +4,8 @@ import (
 	"fmt"
 	"os"
 
+	"github.com/bpicode/fritzctl/cmd/jsonapi"
+	"github.com/bpicode/fritzctl/cmd/printer"
 	"github.com/bpicode/fritzctl/console"
 	"github.com/bpicode/fritzctl/fritz"
 	"github.com/bpicode/fritzctl/logger"
@@ -11,31 +13,40 @@ import (
 )
 
 var listSwitchesCmd = &cobra.Command{
-	Use:     "switches",
-	Short:   "List the available smart home switches",
-	Long:    "List the available smart home devices [switches] and associated data.",
-	Example: "fritzctl list switches",
-	RunE:    listSwitches,
+	Use:   "switches",
+	Short: "List the available smart home switches",
+	Long:  "List the available smart home devices [switches] and associated data.",
+	Example: `fritzctl list switches
+fritzctl list switches --output=json`,
+	RunE: listSwitches,
 }
 
 func init() {
+	listSwitchesCmd.Flags().StringP("output", "o", "", "specify output format")
 	listCmd.AddCommand(listSwitchesCmd)
 }
 
-func listSwitches(_ *cobra.Command, _ []string) error {
+func listSwitches(cmd *cobra.Command, _ []string) error {
 	c := homeAutoClient()
 	devs, err := c.List()
 	assertNoErr(err, "cannot obtain data for smart home switches")
 	logger.Success("Device data:")
-
-	table := switchTable()
-	appendSwitches(devs, table)
-	table.Print(os.Stdout)
+	data := remapSwitches(cmd, devs.Switches())
+	printer.Print(data, os.Stdout)
 	return nil
 }
 
-func switchTable() *console.Table {
-	return console.NewTable(console.Headers(
+func remapSwitches(cmd *cobra.Command, ds []fritz.Device) interface{} {
+	switch cmd.Flag("output").Value.String() {
+	case "json":
+		return jsonapi.NewMapper().Convert(ds)
+	default:
+		return switchTable(ds)
+	}
+}
+
+func switchTable(devs []fritz.Device) *console.Table {
+	table := console.NewTable(console.Headers(
 		"NAME",
 		"PRODUCT",
 		"PRESENT",
@@ -47,10 +58,12 @@ func switchTable() *console.Table {
 		"TEMP",
 		"OFFSET",
 	))
+	appendSwitches(devs, table)
+	return table
 }
 
-func appendSwitches(devs *fritz.Devicelist, table *console.Table) {
-	for _, dev := range devs.Switches() {
+func appendSwitches(devs []fritz.Device, table *console.Table) {
+	for _, dev := range devs {
 		table.Append(switchColumns(dev))
 	}
 }
diff --git a/cmd/list_thermostats.go b/cmd/list_thermostats.go
index 0ab129d..041e39a 100644
--- a/cmd/list_thermostats.go
+++ b/cmd/list_thermostats.go
@@ -5,6 +5,8 @@ import (
 	"os"
 	"time"
 
+	"github.com/bpicode/fritzctl/cmd/jsonapi"
+	"github.com/bpicode/fritzctl/cmd/printer"
 	"github.com/bpicode/fritzctl/console"
 	"github.com/bpicode/fritzctl/fritz"
 	"github.com/bpicode/fritzctl/logger"
@@ -13,42 +15,40 @@ import (
 )
 
 var listThermostatsCmd = &cobra.Command{
-	Use:     "thermostats",
-	Short:   "List the available smart home thermostats",
-	Long:    "List the available smart home devices [thermostats] and associated data.",
-	Example: "fritzctl list thermostats",
-	RunE:    listThermostats,
+	Use:   "thermostats",
+	Short: "List the available smart home thermostats",
+	Long:  "List the available smart home devices [thermostats] and associated data.",
+	Example: `fritzctl list thermostats
+fritzctl list thermostats --output=json`,
+	RunE: listThermostats,
 }
 
 func init() {
+	listThermostatsCmd.Flags().StringP("output", "o", "", "specify output format")
 	listCmd.AddCommand(listThermostatsCmd)
 }
 
-func listThermostats(_ *cobra.Command, _ []string) error {
+func listThermostats(cmd *cobra.Command, _ []string) error {
 	c := homeAutoClient()
 	devs, err := c.List()
 	assertNoErr(err, "cannot obtain thermostats device data")
+	data := remapThermostats(cmd, devs.Thermostats())
 	logger.Success("Device data:")
-
-	table := thermostatsTable()
-	appendThermostats(devs, table)
-	table.Print(os.Stdout)
+	printer.Print(data, os.Stdout)
 	return nil
 }
 
-var errorCodesVsDescriptions = map[string]string{
-	"":  "",
-	"0": "",
-	"1": " Thermostat adjustment not possible. Is the device mounted correctly?",
-	"2": " Valve plunger cannot be driven far enough. Possible solutions: Open and close the plunger a couple of times by hand. Check if the battery is too weak.",
-	"3": " Valve plunger cannot be moved. Is it blocked?",
-	"4": " Preparing installation.",
-	"5": " Device in mode 'INSTALLATION'. It can be mounted now.",
-	"6": " Device is adjusting to the valve plunger.",
+func remapThermostats(cmd *cobra.Command, ds []fritz.Device) interface{} {
+	switch cmd.Flag("output").Value.String() {
+	case "json":
+		return jsonapi.NewMapper().Convert(ds)
+	default:
+		return thermostatsTable(ds)
+	}
 }
 
-func thermostatsTable() *console.Table {
-	return console.NewTable(console.Headers(
+func thermostatsTable(devs []fritz.Device) *console.Table {
+	table := console.NewTable(console.Headers(
 		"NAME",
 		"PRODUCT",
 		"PRESENT",
@@ -62,11 +62,14 @@ func thermostatsTable() *console.Table {
 		"STATE",
 		"BATTERY",
 	))
+	appendThermostats(devs, table)
+	return table
 }
 
-func appendThermostats(devs *fritz.Devicelist, table *console.Table) {
-	for _, dev := range devs.Thermostats() {
-		table.Append(thermostatColumns(dev))
+func appendThermostats(devs []fritz.Device, table *console.Table) {
+	for _, dev := range devs {
+		columns := thermostatColumns(dev)
+		table.Append(columns)
 	}
 }
 
@@ -112,5 +115,5 @@ func fmtNextChange(n fritz.NextChange) string {
 
 func errorCode(ec string) string {
 	checkMark := console.Stoc(ec).Inverse()
-	return checkMark.String() + errorCodesVsDescriptions[ec]
+	return checkMark.String() + fritz.HkrErrorDescriptions[ec]
 }
diff --git a/cmd/printer/print.go b/cmd/printer/print.go
new file mode 100644
index 0000000..f80f303
--- /dev/null
+++ b/cmd/printer/print.go
@@ -0,0 +1,21 @@
+package printer
+
+import (
+	"encoding/json"
+	"io"
+
+	"github.com/bpicode/fritzctl/console"
+)
+
+// Print arbitrates between printable types.
+// If the passed argument is of type *console.Table, we print the table.
+// If the passed argument is of any other type, we encode it as json.
+func Print(data interface{}, writer io.Writer) {
+	if table, ok := data.(*console.Table); ok {
+		table.Print(writer)
+		return
+	}
+	encoder := json.NewEncoder(writer)
+	encoder.SetIndent("", "  ")
+	encoder.Encode(data)
+}
diff --git a/fritz/powermeter.go b/fritz/powermeter.go
index 6e754ec..78968ad 100644
--- a/fritz/powermeter.go
+++ b/fritz/powermeter.go
@@ -5,7 +5,7 @@ import "strconv"
 // Powermeter models a power measurement
 type Powermeter struct {
 	Power  string `xml:"power"`  // Current power, refreshed approx every 2 minutes
-	Energy string `xml:"energy"` // Absolute energy consuption since the device started operating
+	Energy string `xml:"energy"` // Absolute energy consumption since the device started operating
 }
 
 // FmtPowerW formats the value of p.Power as obtained on the http interface as a string, units are W.
diff --git a/fritz/thermostat.go b/fritz/thermostat.go
index b00a5e8..18156e1 100644
--- a/fritz/thermostat.go
+++ b/fritz/thermostat.go
@@ -1,5 +1,17 @@
 package fritz
 
+// HkrErrorDescriptions has a translation of error code to a warning/error/status description.
+var HkrErrorDescriptions = map[string]string{
+	"":  "",
+	"0": "",
+	"1": " Thermostat adjustment not possible. Is the device mounted correctly?",
+	"2": " Valve plunger cannot be driven far enough. Possible solutions: Open and close the plunger a couple of times by hand. Check if the battery is too weak.",
+	"3": " Valve plunger cannot be moved. Is it blocked?",
+	"4": " Preparing installation.",
+	"5": " Device in mode 'INSTALLATION'. It can be mounted now.",
+	"6": " Device is adjusting to the valve plunger.",
+}
+
 // Thermostat models the "HKR" device.
 // codebeat:disable[TOO_MANY_IVARS]
 type Thermostat struct {


Test patch:
diff --git a/cmd/commands_test.go b/cmd/commands_test.go
index 82ffff1..e57dc8d 100644
--- a/cmd/commands_test.go
+++ b/cmd/commands_test.go
@@ -35,13 +35,14 @@ func TestCommands(t *testing.T) {
 		{cmd: planManifestCmd, args: []string{"../testdata/devicelist_fritzos06.83_plan.yml"}, srv: mock.New().UnstartedServer()},
 		{cmd: exportManifestCmd, srv: mock.New().UnstartedServer()},
 		{cmd: applyManifestCmd, args: []string{"../testdata/devicelist_fritzos06.83_plan.yml"}, srv: mock.New().UnstartedServer()},
-		{cmd: listGroupsCmd, args: []string{}, srv: mock.New().UnstartedServer()},
-		{cmd: listLanDevicesCmd, args: []string{}, srv: mock.New().UnstartedServer()},
-		{cmd: listLogsCmd, args: []string{}, srv: mock.New().UnstartedServer()},
-		{cmd: listCallsCmd, args: []string{}, srv: mock.New().UnstartedServer()},
+		{cmd: listGroupsCmd, srv: mock.New().UnstartedServer()},
+		{cmd: listLanDevicesCmd, srv: mock.New().UnstartedServer()},
+		{cmd: listLogsCmd, srv: mock.New().UnstartedServer()},
+		{cmd: listCallsCmd, srv: mock.New().UnstartedServer()},
 		{cmd: listSwitchesCmd, srv: mock.New().UnstartedServer()},
+		{cmd: listSwitchesCmd, args: []string{"--output=json"}, srv: mock.New().UnstartedServer()},
 		{cmd: listThermostatsCmd, srv: mock.New().UnstartedServer()},
-		{cmd: listThermostatsCmd, srv: mock.New().UnstartedServer()},
+		{cmd: listThermostatsCmd, args: []string{"--output=json"}, srv: mock.New().UnstartedServer()},
 		{cmd: docManCmd, srv: mock.New().UnstartedServer()},
 		{cmd: boxInfoCmd, srv: mock.New().UnstartedServer()},
 		{cmd: aboutCmd, srv: mock.New().UnstartedServer()},
@@ -53,6 +54,8 @@ func TestCommands(t *testing.T) {
 			assert.NoError(t, err)
 			testCase.srv.Start()
 			defer testCase.srv.Close()
+			err = testCase.cmd.ParseFlags(testCase.args)
+			assert.NoError(t, err)
 			err = testCase.cmd.RunE(testCase.cmd, testCase.args)
 			assert.NoError(t, err)
 		})
diff --git a/cmd/jsonapi/converter_test.go b/cmd/jsonapi/converter_test.go
new file mode 100644
index 0000000..72c0d2d
--- /dev/null
+++ b/cmd/jsonapi/converter_test.go
@@ -0,0 +1,54 @@
+package jsonapi
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/bpicode/fritzctl/fritz"
+	"github.com/stretchr/testify/assert"
+)
+
+// Test_thMapper_Convert probes the converter.
+func Test_thMapper_Convert(t *testing.T) {
+	m := NewMapper()
+	devices := []fritz.Device{
+		simpleHkr(),
+		problematicPlungerHkr(),
+		simpleSwitch(),
+	}
+	l := m.Convert(devices)
+	bs, err := json.Marshal(l)
+	assert.NoError(t, err)
+	fmt.Println(string(bs))
+	assert.Equal(t, l.NumberOfItems, len(devices))
+}
+
+func simpleHkr() fritz.Device {
+	return fritz.Device{
+		Name:            "myhkr",
+		Functionbitmask: "320",
+		Thermostat: fritz.Thermostat{
+			NextChange: fritz.NextChange{TimeStamp: "121441515", Goal: "35"},
+			BatteryLow: "0",
+		},
+	}
+}
+
+func problematicPlungerHkr() fritz.Device {
+	return fritz.Device{
+		Name:            "myhkrwitherr",
+		Functionbitmask: "320",
+		Thermostat:      fritz.Thermostat{ErrorCode: "2", DeviceLock: "1"},
+	}
+}
+
+func simpleSwitch() fritz.Device {
+	return fritz.Device{
+		Name:            "myswitch",
+		Functionbitmask: "2944",
+		Switch:          fritz.Switch{State: "1", DeviceLock: "0", Lock: "0"},
+		Powermeter:      fritz.Powermeter{Energy: "0", Power: "0"},
+		Temperature:     fritz.Temperature{Celsius: "222"},
+	}
+}
diff --git a/cmd/printer/print_test.go b/cmd/printer/print_test.go
new file mode 100644
index 0000000..a924313
--- /dev/null
+++ b/cmd/printer/print_test.go
@@ -0,0 +1,26 @@
+package printer
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/bpicode/fritzctl/console"
+	"github.com/stretchr/testify/assert"
+)
+
+// TestPrintJSON probes the JSON sector of Print.
+func TestPrintJSON(t *testing.T) {
+	capt := bytes.NewBuffer(nil)
+	Print(struct{ A string }{A: "text"}, capt)
+	assert.Contains(t, capt.String(), "text")
+	assert.Contains(t, capt.String(), "A")
+}
+
+// TestPrintTable probes the table sector of Print.
+func TestPrintTable(t *testing.T) {
+	capt := bytes.NewBuffer(nil)
+	table := console.NewTable(console.Headers("X"))
+	Print(table, capt)
+	assert.Contains(t, capt.String(), "X")
+	assert.Contains(t, capt.String(), "+--")
+}