Repository: bpicode/fritzctl
Pull request #104 (2017-11-12 12:34:08)

support "list phonecalls" to see a list of recently made calls


Patch:
diff --git a/cmd/list_phonecalls.go b/cmd/list_phonecalls.go
new file mode 100644
index 0000000..25908e0
--- /dev/null
+++ b/cmd/list_phonecalls.go
@@ -0,0 +1,57 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/bpicode/fritzctl/console"
+	"github.com/bpicode/fritzctl/fritz"
+	"github.com/bpicode/fritzctl/logger"
+	"github.com/spf13/cobra"
+)
+
+var listCallsCmd = &cobra.Command{
+	Use:     "phonecalls",
+	Short:   "List recent phone calls",
+	Long:    "List recently made phone calls.",
+	Example: "fritzctl list phonecalls",
+	RunE:    listCalls,
+}
+
+var callTypeVsDescription = map[string]string{
+	"1": "incoming",
+	"2": "missed",
+	"3": "rejected",
+	"4": "outgoing",
+}
+
+func init() {
+	listCmd.AddCommand(listCallsCmd)
+}
+
+func listCalls(_ *cobra.Command, _ []string) error {
+	c := clientLogin()
+	f := fritz.NewPhone(c)
+	calls, err := f.Calls()
+	assertNoErr(err, "cannot obtain phone calls")
+	logger.Success("Recent phone calls:\n")
+	printCalls(calls)
+	return nil
+}
+
+func printCalls(calls []fritz.Call) {
+	table := console.NewTable(console.Headers("TYPE", "DATE", "CALLER", "DURATION"))
+	for _, c := range calls {
+		table.Append(callColumns(c))
+	}
+	table.Print(os.Stdout)
+}
+
+func callColumns(c fritz.Call) []string {
+	return []string{
+		callTypeVsDescription[c.Type],
+		c.Date,
+		fmt.Sprintf("%s %s", c.Caller, c.PhoneNumber),
+		c.Duration,
+	}
+}
diff --git a/fritz/ain_based.go b/fritz/ain_based.go
index eafbc26..6883073 100644
--- a/fritz/ain_based.go
+++ b/fritz/ain_based.go
@@ -2,10 +2,8 @@ package fritz
 
 import (
 	"fmt"
-	"net/http"
 
 	"github.com/bpicode/fritzctl/httpread"
-	"github.com/bpicode/fritzctl/logger"
 )
 
 // ainBased API definition, guided by
@@ -27,20 +25,13 @@ type ainBasedClient struct {
 	client *Client
 }
 
-func (a *ainBasedClient) getf(url string) func() (*http.Response, error) {
-	return func() (*http.Response, error) {
-		logger.Debug("GET", url)
-		return a.client.HTTPClient.Get(url)
-	}
-}
-
 // listDevices lists the basic data of the smart home devices.
 func (a *ainBasedClient) listDevices() (*Devicelist, error) {
 	url := a.homeAutoSwitch().
 		query("switchcmd", "getdevicelistinfos").
 		build()
 	var deviceList Devicelist
-	errRead := httpread.XML(a.getf(url), &deviceList)
+	errRead := httpread.XML(a.client.getf(url), &deviceList)
 	return &deviceList, errRead
 }
 
@@ -60,7 +51,7 @@ func (a *ainBasedClient) toggle(ain string) (string, error) {
 		query("ain", ain).
 		query("switchcmd", "setswitchtoggle").
 		build()
-	return httpread.String(a.getf(url))
+	return httpread.String(a.client.getf(url))
 }
 
 // applyTemperature sets the desired temperature on a "HKR" device. The device is identified by its AIN.
@@ -74,7 +65,7 @@ func (a *ainBasedClient) applyTemperature(value float64, ain string) (string, er
 		query("switchcmd", "sethkrtsoll").
 		query("param", fmt.Sprintf("%d", param)).
 		build()
-	return httpread.String(a.getf(url))
+	return httpread.String(a.client.getf(url))
 }
 
 func (a *ainBasedClient) switchForAin(ain, command string) (string, error) {
@@ -82,11 +73,11 @@ func (a *ainBasedClient) switchForAin(ain, command string) (string, error) {
 		query("ain", ain).
 		query("switchcmd", command).
 		build()
-	return httpread.String(a.getf(url))
+	return httpread.String(a.client.getf(url))
 }
 
 func (a *ainBasedClient) homeAutoSwitch() fritzURLBuilder {
-	return newURLBuilder(a.client.Config).path(homeAutomationURI).query("sid", a.client.SessionInfo.SID)
+	return a.client.query().path(homeAutomationURI)
 }
 
 func temperatureParam(t float64) (int64, error) {
diff --git a/fritz/api_internal.go b/fritz/api_internal.go
index 850ecfc..4a408c8 100644
--- a/fritz/api_internal.go
+++ b/fritz/api_internal.go
@@ -1,10 +1,7 @@
 package fritz
 
 import (
-	"net/http"
-
 	"github.com/bpicode/fritzctl/httpread"
-	"github.com/bpicode/fritzctl/logger"
 )
 
 // Internal exposes Fritz!Box internal and undocumented API.
@@ -30,7 +27,7 @@ func (i *internal) ListLogs() (*MessageLog, error) {
 		query("mq_log", "logger:status/log").
 		build()
 	var logs MessageLog
-	err := httpread.JSON(i.getf(url), &logs)
+	err := httpread.JSON(i.client.getf(url), &logs)
 	return &logs, err
 }
 
@@ -41,7 +38,7 @@ func (i *internal) ListLanDevices() (*LanDevices, error) {
 		query("network", "landevice:settings/landevice/list(name,ip,mac,UID,dhcp,wlan,ethernet,active,wakeup,deleteable,source,online,speed,guest,url)").
 		build()
 	var devs LanDevices
-	err := httpread.JSON(i.getf(url), &devs)
+	err := httpread.JSON(i.client.getf(url), &devs)
 	return &devs, err
 }
 
@@ -53,21 +50,14 @@ func (i *internal) InternetStats() (*TrafficMonitoringData, error) {
 		query("action", "get_graphic").
 		build()
 	var data []TrafficMonitoringData
-	err := httpread.JSON(i.getf(url), &data)
+	err := httpread.JSON(i.client.getf(url), &data)
 	return &data[0], err
 }
 
 func (i *internal) query() fritzURLBuilder {
-	return newURLBuilder(i.client.Config).path(queryURI).query("sid", i.client.SessionInfo.SID)
+	return i.client.query().path(queryURI)
 }
 
 func (i *internal) inetStat() fritzURLBuilder {
-	return newURLBuilder(i.client.Config).path(inetStatURI).query("sid", i.client.SessionInfo.SID)
-}
-
-func (i *internal) getf(url string) func() (*http.Response, error) {
-	return func() (*http.Response, error) {
-		logger.Debug("GET", url)
-		return i.client.HTTPClient.Get(url)
-	}
+	return i.client.query().path(inetStatURI)
 }
diff --git a/fritz/api_phone.go b/fritz/api_phone.go
new file mode 100644
index 0000000..d4c2fcd
--- /dev/null
+++ b/fritz/api_phone.go
@@ -0,0 +1,59 @@
+package fritz
+
+import (
+	"github.com/bpicode/fritzctl/httpread"
+	"github.com/pkg/errors"
+)
+
+// NewPhone creates a client for interaction with the FB phone sector.
+func NewPhone(client *Client) Phone {
+	return &phone{client: client}
+}
+
+// Phone describes the supported operations.
+type Phone interface {
+	Calls() ([]Call, error)
+}
+
+// Call contains the data for one phone call record.
+// codebeat:disable[TOO_MANY_IVARS]
+type Call struct {
+	Type           string // "1"=incoming, "2"=missed call, "3"=unknown, "4"=outgoing.
+	Date           string
+	Caller         string
+	PhoneNumber    string
+	Extension      string
+	OwnPhoneNumber string
+	Duration       string
+}
+
+// codebeat:enable[TOO_MANY_IVARS]
+
+type phone struct {
+	client *Client
+}
+
+// Calls reads the phone call record list from FB.
+func (p *phone) Calls() ([]Call, error) {
+	url := p.client.query().path(phoneListURI).query("csv", "").build()
+	records, err := httpread.Csv(p.client.getf(url), ';')
+	if err != nil {
+		return nil, errors.Wrap(err, "unable read data for phone calls")
+	}
+	var calls []Call
+	for _, r := range records[2:] { // Skip first two lines.
+		calls = append(calls, convertRecordToCall(r))
+	}
+	return calls, err
+}
+func convertRecordToCall(r []string) Call {
+	return Call{
+		Type:           r[0],
+		Date:           r[1],
+		Caller:         r[2],
+		PhoneNumber:    r[3],
+		Extension:      r[4],
+		OwnPhoneNumber: r[5],
+		Duration:       r[6],
+	}
+}
diff --git a/fritz/fritzapi_constants.go b/fritz/fritzapi_constants.go
index 7ec34ec..ae3e482 100644
--- a/fritz/fritzapi_constants.go
+++ b/fritz/fritzapi_constants.go
@@ -13,6 +13,7 @@ const (
 	homeAutomationURI = "/webservices/homeautoswitch.lua"
 	queryURI          = "/query.lua"
 	inetStatURI       = "/internet/inetstat_monitor.lua"
+	phoneListURI      = "/fon_num/foncalls_list.lua"
 )
 
 type fritzURLBuilder interface {
diff --git a/fritz/fritzclient.go b/fritz/fritzclient.go
index 93f63a6..9d93a0d 100644
--- a/fritz/fritzclient.go
+++ b/fritz/fritzclient.go
@@ -121,3 +121,14 @@ func buildCertPool(cfg *config.Config) *x509.CertPool {
 	}
 	return caCertPool
 }
+
+func (client *Client) query() fritzURLBuilder {
+	return newURLBuilder(client.Config).query("sid", client.SessionInfo.SID)
+}
+
+func (client *Client) getf(url string) func() (*http.Response, error) {
+	return func() (*http.Response, error) {
+		logger.Debug("GET", url)
+		return client.HTTPClient.Get(url)
+	}
+}
diff --git a/httpread/httpread.go b/httpread/httpread.go
index b9a8bad..53652c1 100644
--- a/httpread/httpread.go
+++ b/httpread/httpread.go
@@ -2,6 +2,7 @@ package httpread
 
 import (
 	"bytes"
+	"encoding/csv"
 	"encoding/json"
 	"encoding/xml"
 	"fmt"
@@ -52,6 +53,37 @@ func String(f func() (*http.Response, error)) (string, error) {
 	return body, nil
 }
 
+type csvDecoder struct {
+	reader io.Reader
+	comma  rune
+}
+
+func (c *csvDecoder) Decode(v interface{}) error {
+	cr := csv.NewReader(c.reader)
+	cr.Comma = c.comma
+	cr.FieldsPerRecord = -1
+	records, err := cr.ReadAll()
+	if err != nil {
+		return err
+	}
+	t, ok := v.(*[][]string)
+	if !ok {
+		return errors.New("cannot decode into csv, call with *[][]string slice")
+	}
+	*t = records
+	return nil
+}
+
+// Csv reads a http response into a [][]string.
+// The response is checked for its status code and the http.Response.Body is closed.
+func Csv(f func() (*http.Response, error), comma rune) ([][]string, error) {
+	var records [][]string
+	err := readDecode(f, func(r io.Reader) decoder {
+		return &csvDecoder{reader: r, comma: comma}
+	}, &records)
+	return records, err
+}
+
 func guessStatusCode(body string) (int, string) {
 	// There are web servers that send the wrong status code, but provide some hint in the text/html.
 	for k, v := range httpStatusBuzzwords {
diff --git a/mock/calls.csv b/mock/calls.csv
new file mode 100644
index 0000000..1713971
--- /dev/null
+++ b/mock/calls.csv
@@ -0,0 +1,5 @@
+sep=;
+Typ;Datum;Name;Rufnummer;Nebenstelle;Eigene Rufnummer;Dauer
+1;25.04.13 15:15;- NAME1;000000000000;NAME2;0000000;0:03
+1;24.04.13 15:53;- NAME1;000000000000;NAME2;0000000;0:07
+1;23.04.13 20:33;NAME1;000000000000;NAME2;0000000;0:23
\ No newline at end of file
diff --git a/mock/calls_none.csv b/mock/calls_none.csv
new file mode 100644
index 0000000..887fb95
--- /dev/null
+++ b/mock/calls_none.csv
@@ -0,0 +1,2 @@
+sep=;
+Typ;Datum;Name;Rufnummer;Nebenstelle;Eigene Rufnummer;Dauer
diff --git a/mock/fritzbox_mock.go b/mock/fritzbox_mock.go
index efb8f7b..2cfdb50 100644
--- a/mock/fritzbox_mock.go
+++ b/mock/fritzbox_mock.go
@@ -19,6 +19,7 @@ type Fritz struct {
 	Logs                   string
 	LanDevices             string
 	InetStats              string
+	PhoneCalls             string
 	Server                 *httptest.Server
 }
 
@@ -33,6 +34,7 @@ func New() *Fritz {
 		Logs:                   "../mock/logs.json",
 		LanDevices:             "../mock/landevices.json",
 		InetStats:              "../mock/traffic.json",
+		PhoneCalls:             "../mock/calls.csv",
 	}
 }
 
@@ -61,6 +63,7 @@ func (f *Fritz) fritzRoutes() *httprouter.Router {
 	router.GET("/webservices/homeautoswitch.lua", f.homeAutoHandler)
 	router.GET("/query.lua", f.queryHandler)
 	router.GET("/internet/inetstat_monitor.lua", f.inetStatHandler)
+	router.GET("/fon_num/foncalls_list.lua", f.phoneCallsHandler)
 	return router
 }
 
@@ -122,3 +125,7 @@ func (f *Fritz) preProcess(w http.ResponseWriter, r *http.Request) bool {
 	}
 	return true
 }
+
+func (f *Fritz) phoneCallsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	f.writeFromFs(w, f.PhoneCalls)
+}


Test patch:
diff --git a/cmd/commands_test.go b/cmd/commands_test.go
index a423687..c056939 100644
--- a/cmd/commands_test.go
+++ b/cmd/commands_test.go
@@ -35,12 +35,13 @@ func TestCommands(t *testing.T) {
 		{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: listSwitchesCmd, srv: mock.New().UnstartedServer()},
 		{cmd: listThermostatsCmd, srv: mock.New().UnstartedServer()},
 		{cmd: docManCmd, srv: mock.New().UnstartedServer()},
 	}
-	for i, testCase := range testCases {
-		t.Run(fmt.Sprintf("Test run command %d", i), func(t *testing.T) {
+	for _, testCase := range testCases {
+		t.Run(fmt.Sprintf("Test run command %s", testCase.cmd.Name()), func(t *testing.T) {
 			l, err := net.Listen("tcp", ":61666")
 			assert.NoError(t, err)
 			testCase.srv.Listener = l
@@ -55,8 +56,8 @@ func TestCommands(t *testing.T) {
 // TestCommandsHaveHelp ensures that every command provides
 // a help text.
 func TestCommandsHaveHelp(t *testing.T) {
-	for i, c := range coreCommands() {
-		t.Run(fmt.Sprintf("test long description of command %d", i), func(t *testing.T) {
+	for _, c := range allCommands(RootCmd) {
+		t.Run(fmt.Sprintf("test long description of command %s", c.Name()), func(t *testing.T) {
 			assert.NotEmpty(t, c.Long)
 		})
 	}
@@ -64,8 +65,8 @@ func TestCommandsHaveHelp(t *testing.T) {
 
 // TestCommandsHaveUsage tests that command have a usage pattern.
 func TestCommandsHaveUsage(t *testing.T) {
-	for i, c := range allCommands() {
-		t.Run(fmt.Sprintf("test usage term of command %d", i), func(t *testing.T) {
+	for _, c := range allCommands(RootCmd) {
+		t.Run(fmt.Sprintf("test usage term of command %s", c.Name()), func(t *testing.T) {
 			assert.NotEmpty(t, c.Use)
 		})
 	}
@@ -74,43 +75,18 @@ func TestCommandsHaveUsage(t *testing.T) {
 // TestCommandsHaveSynopsis ensures that every command provides
 // short a synopsis text.
 func TestCommandsHaveSynopsis(t *testing.T) {
-	for i, c := range coreCommands() {
-		t.Run(fmt.Sprintf("test short description of command %d", i), func(t *testing.T) {
+	for _, c := range allCommands(RootCmd) {
+		t.Run(fmt.Sprintf("test short description of command %s", c.Name()), func(t *testing.T) {
 			assert.NotEmpty(t, c.Short)
 		})
 	}
 }
 
-func allCommands() []*cobra.Command {
-	all := []*cobra.Command{
-		versionCmd,
-		switchCmd,
-		manifestCmd,
-		listCmd,
-		docCmd,
-	}
-	core := coreCommands()
-	all = append(all, core...)
-	return all
-}
-
-func coreCommands() []*cobra.Command {
-	return []*cobra.Command{
-		versionCmd,
-		toggleCmd,
-		temperatureCmd,
-		switchOnCmd,
-		switchOffCmd,
-		sessionIDCmd,
-		pingCmd,
-		planManifestCmd,
-		exportManifestCmd,
-		applyManifestCmd,
-		listGroupsCmd,
-		listLanDevicesCmd,
-		listLogsCmd,
-		listSwitchesCmd,
-		listThermostatsCmd,
-		docManCmd,
+func allCommands(cmd *cobra.Command) []*cobra.Command {
+	var commands []*cobra.Command
+	commands = append(commands, cmd)
+	for _, sub := range cmd.Commands() {
+		commands = append(commands, allCommands(sub)...)
 	}
+	return commands
 }
diff --git a/fritz/api_internal_test.go b/fritz/api_internal_test.go
index 2e40b9c..96002bc 100644
--- a/fritz/api_internal_test.go
+++ b/fritz/api_internal_test.go
@@ -11,7 +11,7 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-// TestInternalFritzAPI test the FRITZ API.
+// TestInternalFritzAPI tests the FRITZ API.
 func TestInternalFritzAPI(t *testing.T) {
 	testCases := []struct {
 		tc func(t *testing.T, internal Internal)
diff --git a/fritz/api_phone_test.go b/fritz/api_phone_test.go
new file mode 100644
index 0000000..6a7f8af
--- /dev/null
+++ b/fritz/api_phone_test.go
@@ -0,0 +1,63 @@
+package fritz
+
+import (
+	"fmt"
+	"net/url"
+	"reflect"
+	"runtime"
+	"testing"
+
+	"github.com/bpicode/fritzctl/mock"
+	"github.com/stretchr/testify/assert"
+)
+
+// TestPhone tests the FRITZ/Phone API.
+func TestPhone(t *testing.T) {
+	testCases := []struct {
+		tc func(t *testing.T, phone Phone, srv *mock.Fritz)
+	}{
+		{testCalls},
+		{testCallsNoRecords},
+		{testCallsWithServerDown},
+	}
+	for _, testCase := range testCases {
+		t.Run(fmt.Sprintf("Test phone api %s", runtime.FuncForPC(reflect.ValueOf(testCase.tc).Pointer()).Name()), func(t *testing.T) {
+			srv := mock.New().Start()
+			defer srv.Close()
+
+			client, err := NewClient("../mock/client_config_template.json")
+			assert.NoError(t, err)
+			u, err := url.Parse(srv.Server.URL)
+			assert.NoError(t, err)
+			client.Config.Net.Protocol = u.Scheme
+			client.Config.Net.Host = u.Host
+
+			err = client.Login()
+			assert.NoError(t, err)
+
+			phone := NewPhone(client)
+			assert.NotNil(t, phone)
+			testCase.tc(t, phone, srv)
+		})
+	}
+}
+
+func testCalls(t *testing.T, phone Phone, _ *mock.Fritz) {
+	calls, err := phone.Calls()
+	assert.NoError(t, err)
+	assert.NotNil(t, calls)
+	assert.Len(t, calls, 3)
+}
+
+func testCallsNoRecords(t *testing.T, phone Phone, srv *mock.Fritz) {
+	srv.PhoneCalls = "../mock/calls_none.csv"
+	calls, err := phone.Calls()
+	assert.NoError(t, err)
+	assert.Empty(t, calls)
+}
+
+func testCallsWithServerDown(t *testing.T, phone Phone, srv *mock.Fritz) {
+	srv.Close()
+	_, err := phone.Calls()
+	assert.Error(t, err)
+}
diff --git a/httpread/httpread_test.go b/httpread/httpread_test.go
index 338e3b6..d971be4 100644
--- a/httpread/httpread_test.go
+++ b/httpread/httpread_test.go
@@ -113,3 +113,41 @@ func TestStringDecoder(t *testing.T) {
 	assert.Error(t, (&stringDecoder{reader: strings.NewReader("somevalue")}).Decode(&struct{}{}))
 	assert.NoError(t, (&stringDecoder{reader: strings.NewReader("somevalue")}).Decode(new(string)))
 }
+
+// TestCsv tests the Csv happy path.
+func TestCsv(t *testing.T) {
+	resp := &http.Response{
+		StatusCode: 200,
+		Status:     "OK",
+		Body:       ioutil.NopCloser(strings.NewReader("x,y,z\n1,2,3")),
+	}
+
+	records, err := Csv(func() (*http.Response, error) {
+		return resp, nil
+	}, ',')
+	assert.NoError(t, err)
+	assert.NotNil(t, records)
+	assert.Len(t, records, 2)
+	assert.Len(t, records[0], 3)
+	assert.Len(t, records[1], 3)
+}
+
+// TestCsvErrorAtDecoding tests errors at decoding stage.
+func TestCsvErrorAtDecoding(t *testing.T) {
+	resp := &http.Response{
+		StatusCode: 200,
+		Status:     "OK",
+		Body:       ioutil.NopCloser(&errorReader{}),
+	}
+	_, err := Csv(func() (*http.Response, error) {
+		return resp, nil
+	}, ',')
+	assert.Error(t, err)
+}
+
+// TestCsvDecoderCalledWithRubbish asserts a panic-free mis-usage when called with anything else than *[][]string.
+func TestCsvDecoderCalledWithRubbish(t *testing.T) {
+	c := csvDecoder{reader: strings.NewReader("x,y,z\n1,2,3"), comma: ','}
+	err := c.Decode(683)
+	assert.Error(t, err)
+}
diff --git a/mock/fritzbox_mock_test.go b/mock/fritzbox_mock_test.go
index 0e551d3..1a1ec94 100644
--- a/mock/fritzbox_mock_test.go
+++ b/mock/fritzbox_mock_test.go
@@ -106,6 +106,15 @@ func TestResponseWhenBackendFileNotFound(t *testing.T) {
 	assert5xxResponse(t, r)
 }
 
+func TestPhoneCalls(t *testing.T) {
+	fritz := New()
+	fritz.Start()
+	defer fritz.Close()
+
+	r, _ := (&http.Client{}).Get(fritz.Server.URL + "/fon_num/foncalls_list.lua")
+	assert2xxResponse(t, r)
+}
+
 func assert2xxResponse(t *testing.T, r *http.Response) {
 	assert.True(t, r.StatusCode >= 200)
 	assert.True(t, r.StatusCode < 300)