Repository: bpicode/fritzctl
Pull request #186 (2018-12-18 19:41:40)

Issue #168: support buttons in go/json API and in CLI via `fritzctl l…
…ist buttons`


Patch:
diff --git a/cmd/jsonapi/converter.go b/cmd/jsonapi/converter.go
index cd92c60..75b1a62 100644
--- a/cmd/jsonapi/converter.go
+++ b/cmd/jsonapi/converter.go
@@ -108,6 +108,7 @@ func (m *mapper) mapMeasurements(target *Device, src *fritz.Device) {
 		meas.EnergyConsumption = src.Powermeter.FmtEnergyWh()
 	}
 	meas.AlertSignal = alertSignalLookup[src.AlertSensor.State]
+	meas.ButtonLastPressed = src.Button.LastPressed()
 	target.Measurements = meas
 }
 
diff --git a/cmd/jsonapi/model.go b/cmd/jsonapi/model.go
index 0c69d4e..a560d05 100644
--- a/cmd/jsonapi/model.go
+++ b/cmd/jsonapi/model.go
@@ -1,5 +1,7 @@
 package jsonapi
 
+import "time"
+
 // codebeat:disable[TOO_MANY_IVARS]
 
 // DeviceList wraps a collection of devices.
@@ -40,10 +42,11 @@ type Lock struct {
 
 // 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.
-	AlertSignal       string `json:"alertSignal"`                 // "ON", "OFF" (if the device reports an alert) or "" (if unknown or does not apply).
+	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.
+	AlertSignal       string     `json:"alertSignal,omitempty"`       // "ON", "OFF" (if the device reports an alert) or "" (if unknown or does not apply).
+	ButtonLastPressed *time.Time `json:"buttonLastPressed,omitEmpty"` // Timestamp when the button was last pressed.
 }
 
 // State contains the core domain of the device, e.g. "is the switch on?", "what is the room temperature supposed to be?".
diff --git a/cmd/list_buttons.go b/cmd/list_buttons.go
new file mode 100644
index 0000000..e5537f3
--- /dev/null
+++ b/cmd/list_buttons.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+	"os"
+	"time"
+
+	"github.com/bpicode/fritzctl/cmd/printer"
+	"github.com/bpicode/fritzctl/fritz"
+	"github.com/bpicode/fritzctl/internal/console"
+	"github.com/bpicode/fritzctl/logger"
+	"github.com/spf13/cobra"
+)
+
+var listButtonsCmd = &cobra.Command{
+	Use:     "buttons",
+	Short:   "List the smart-home buttons",
+	Long:    "List the all smart-home devices recognized as pressable buttons.",
+	Example: "fritzctl list buttons",
+	RunE:    listButtons,
+}
+
+func init() {
+	listButtonsCmd.Flags().StringP("output", "o", "", "specify output format")
+	listCmd.AddCommand(listButtonsCmd)
+}
+
+func listButtons(cmd *cobra.Command, _ []string) error {
+	devs := mustList()
+	data := selectFmt(cmd, devs.Buttons(), buttonsTable)
+	logger.Success("Device data:")
+	printer.Print(data, os.Stdout)
+	return nil
+}
+
+func buttonsTable(devs []fritz.Device) interface{} {
+	table := console.NewTable(console.Headers(
+		"NAME",
+		"LAST PRESSED",
+	))
+	referenceTime := time.Now()
+	for _, dev := range devs {
+		columns := []string{dev.Name, dev.Button.FmtLastPressedCompact(referenceTime)}
+		table.Append(columns)
+	}
+	return table
+}
diff --git a/fritz/button.go b/fritz/button.go
new file mode 100644
index 0000000..f853efe
--- /dev/null
+++ b/fritz/button.go
@@ -0,0 +1,38 @@
+package fritz
+
+import (
+	"strconv"
+	"time"
+)
+
+// Button collects data from devices that have a pressable button.
+type Button struct {
+	LastPressedTimestamp string `xml:"lastpressedtimestamp"` // Timestamp (in epoch seconds) when the button was last pressed. "0" or "" if unknown.
+}
+
+// LastPressed returns the time when the button was last pressed. It returns nil on absence of this information or upon parsing errors.
+func (b *Button) LastPressed() *time.Time {
+	unix, err := strconv.ParseInt(b.LastPressedTimestamp, 10, 64)
+	if err != nil {
+		return nil
+	}
+	t := time.Unix(unix, 0)
+	return &t
+}
+
+// FmtLastPressedCompact returns a compact format of the last pressed timestamp.
+func (b *Button) FmtLastPressedCompact(ref time.Time) string {
+	t := b.LastPressed()
+	if t == nil {
+		return ""
+	}
+	ry, rm, rd := ref.Date()
+	ty, tm, td := t.Date()
+	if ry != ty {
+		return t.Format("Mon Jan 2 15:04:05 2006")
+	}
+	if rm != tm || rd != td {
+		return t.Format("Mon Jan 2 15:04:05")
+	}
+	return t.Format("15:04:05")
+}
diff --git a/fritz/device.go b/fritz/device.go
index 5f0ce0a..818b4a8 100644
--- a/fritz/device.go
+++ b/fritz/device.go
@@ -37,7 +37,8 @@ type Device struct {
 	Powermeter      Powermeter  `xml:"powermeter"`           // Only filled with sensible data for devices with an energy actuator.
 	Temperature     Temperature `xml:"temperature"`          // Only filled with sensible data for devices with a temperature sensor.
 	Thermostat      Thermostat  `xml:"hkr"`                  // Thermostat data, only filled with sensible data for HKR devices.
-	AlertSensor     AlertSensor `xml:"alert"`
+	AlertSensor     AlertSensor `xml:"alert"`                // Only filled with sensible data for devices with an alert sensor.
+	Button          Button      `xml:"button"`               // Button data, only filled with sensible data for button devices.
 }
 
 // codebeat:enable[TOO_MANY_IVARS]
diff --git a/fritz/devicelist.go b/fritz/devicelist.go
index e5ef286..05a276c 100644
--- a/fritz/devicelist.go
+++ b/fritz/devicelist.go
@@ -36,6 +36,13 @@ func (l *Devicelist) AlertSensors() []Device {
 	})
 }
 
+// Buttons returns the devices which have a pressable button.
+func (l *Devicelist) Buttons() []Device {
+	return l.filter(func(d Device) bool {
+		return d.Button.LastPressedTimestamp != ""
+	})
+}
+
 func (l *Devicelist) filter(predicate func(Device) bool) []Device {
 	var filtered []Device
 	for _, d := range l.Devices {
diff --git a/mock/devicelist.xml b/mock/devicelist.xml
index e9f1323..583c80d 100644
--- a/mock/devicelist.xml
+++ b/mock/devicelist.xml
@@ -166,6 +166,16 @@
         </alert>
     </device>
 
+
+    <device identifier="214124 34625478542353" id="19" functionbitmask="8193" fwversion="03.54" manufacturer="AVM"
+            productname="Motion Detector">
+        <present>1</present>
+        <name>BTN_1</name>
+        <button>
+            <lastpressedtimestamp>1545160121</lastpressedtimestamp>
+        </button>
+    </device>
+
     <group identifier="45:FF:99-900" id="960" functionbitmask="320" fwversion="1.0"
            manufacturer="AVM" productname="">
         <present>1</present>


Test patch:
diff --git a/cmd/commands_test.go b/cmd/commands_test.go
index 3f58081..8f3d298 100644
--- a/cmd/commands_test.go
+++ b/cmd/commands_test.go
@@ -39,6 +39,8 @@ func TestCommands(t *testing.T) {
 		{cmd: applyManifestCmd, args: []string{"../testdata/devicelist_fritzos06.83_plan.yml"}, srv: mock.New().UnstartedServer()},
 		{cmd: listAlertsCmd, srv: mock.New().UnstartedServer()},
 		{cmd: listAlertsCmd, args: []string{"--output=json"}, srv: mock.New().UnstartedServer()},
+		{cmd: listButtonsCmd, srv: mock.New().UnstartedServer()},
+		{cmd: listButtonsCmd, args: []string{"--output=json"}, srv: mock.New().UnstartedServer()},
 		{cmd: listGroupsCmd, srv: mock.New().UnstartedServer()},
 		{cmd: listLanDevicesCmd, srv: mock.New().UnstartedServer()},
 		{cmd: listLogsCmd, srv: mock.New().UnstartedServer()},
diff --git a/fritz/button_test.go b/fritz/button_test.go
new file mode 100644
index 0000000..a45ce6e
--- /dev/null
+++ b/fritz/button_test.go
@@ -0,0 +1,40 @@
+package fritz
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// TestButton_LastPressed test the LastPressed method of Button.
+func TestButton_LastPressed(t *testing.T) {
+	genesis := time.Unix(0, 0)
+	eveningOfDec18 := time.Unix(1545160121, 0)
+	tests := []struct {
+		name string
+		btn  Button
+		want *time.Time
+	}{
+		{name: "no data", btn: Button{}, want: nil},
+		{name: "nonsense data", btn: Button{LastPressedTimestamp: "lol"}, want: nil},
+		{name: "genesis", btn: Button{LastPressedTimestamp: "0"}, want: &genesis},
+		{name: "evening of December 18th 2018", btn: Button{LastPressedTimestamp: "1545160121"}, want: &eveningOfDec18},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			last := tc.btn.LastPressed()
+			assert.Equal(t, tc.want, last)
+		})
+	}
+}
+
+// TestButton_FmtLastPressedCompact test the FmtLastPressedCompact method of Button.
+func TestButton_FmtLastPressedCompact(t *testing.T) {
+	genesis := time.Unix(0, 0)
+	oneDayAfterGenesis := genesis.Add(time.Hour * 24)
+	assert.Empty(t, (&Button{}).FmtLastPressedCompact(genesis))
+	assert.NotEmpty(t, (&Button{LastPressedTimestamp: "0"}).FmtLastPressedCompact(genesis))
+	assert.NotEmpty(t, (&Button{LastPressedTimestamp: "0"}).FmtLastPressedCompact(oneDayAfterGenesis))
+	assert.NotEmpty(t, (&Button{LastPressedTimestamp: "1545160121"}).FmtLastPressedCompact(genesis))
+}
diff --git a/fritz/devicelist_test.go b/fritz/devicelist_test.go
index 24e4a10..cdbe33d 100644
--- a/fritz/devicelist_test.go
+++ b/fritz/devicelist_test.go
@@ -8,30 +8,26 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-// TestSwitchAndThermostatFiltering tests on the correctness of the switch/thermostat separation of a given device list.
-func TestSwitchAndThermostatFiltering(t *testing.T) {
-	l := mustUnmarshall(t, "../testdata/devicelist_fritzos06.83.xml")
-
-	assert.Len(t, l.Thermostats(), 2)
-	assert.Len(t, l.Switches(), 1)
-
-	assert.Equal(t, len(l.Devices), len(l.Switches())+len(l.Thermostats()))
-}
-
-// TestSwitchAndThermostatFilteringIssue56 reproduces https://github.com/bpicode/fritzctl/issues/59.
-func TestSwitchAndThermostatFilteringIssue56(t *testing.T) {
-	l := mustUnmarshall(t, "../testdata/devicelist_issue_59.xml")
-
-	assert.Len(t, l.Thermostats(), 4)
-	assert.Len(t, l.Switches(), 8)
-
-	assert.Equal(t, len(l.Devices), len(l.Switches())+len(l.Thermostats()))
-}
-
-// TestAlertSensorFilter tests filtering by alert sensor capabilities.
-func TestAlertSensorFilter(t *testing.T) {
-	l := mustUnmarshall(t, "../testdata/devicelist_fritzos06.83.xml")
-	assert.Len(t, l.AlertSensors(), 0)
+// TestDeviceFiltering tests the filter API of DeviceList.
+func TestDeviceFiltering(t *testing.T) {
+	for _, tc := range []struct {
+		name    string
+		xmlfile string
+		filter  func(Devicelist) []Device
+		expect  int
+	}{
+		{name: "two thermostats", xmlfile: "../testdata/devicelist_fritzos06.83.xml", filter: func(d Devicelist) []Device { return d.Thermostats() }, expect: 2},
+		{name: "one switch", xmlfile: "../testdata/devicelist_fritzos06.83.xml", filter: func(d Devicelist) []Device { return d.Switches() }, expect: 1},
+		{name: "on alert sensors", xmlfile: "../testdata/devicelist_fritzos06.83.xml", filter: func(d Devicelist) []Device { return d.AlertSensors() }, expect: 0},
+		{name: "on buttons", xmlfile: "../testdata/devicelist_fritzos06.83.xml", filter: func(d Devicelist) []Device { return d.Buttons() }, expect: 0},
+		{name: "four thermostats (Issue #56)", xmlfile: "../testdata/devicelist_issue_59.xml", filter: func(d Devicelist) []Device { return d.Thermostats() }, expect: 4},
+		{name: "eight switches (Issue #56)", xmlfile: "../testdata/devicelist_issue_59.xml", filter: func(d Devicelist) []Device { return d.Switches() }, expect: 8},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			list := mustUnmarshall(t, tc.xmlfile)
+			assert.Len(t, tc.filter(list), tc.expect)
+		})
+	}
 }
 
 // TestGroupsIssue56 tests the group un-marshalling.