Repository: bpicode/fritzctl (go)

Issue:
Command "boxinfo"
Call https://fritz.box/cgi-bin/system_status

Pull request description:
Issue #116: display fbox info via new command "fritzctl boxinfo"
Closes #116 

Patch:
diff --git a/cmd/boxinfo.go b/cmd/boxinfo.go
new file mode 100644
index 0000000..757cdc5
--- /dev/null
+++ b/cmd/boxinfo.go
@@ -0,0 +1,36 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/bpicode/fritzctl/console"
+	"github.com/bpicode/fritzctl/fritz"
+	"github.com/spf13/cobra"
+)
+
+var boxInfoCmd = &cobra.Command{
+	Use:     "boxinfo",
+	Short:   "Display information about the FRITZ!Box",
+	Long:    "Show information about the FRITZ!Box like firmware version, uptime, etc.",
+	Example: "fritzctl boxinfo",
+	RunE:    boxInfo,
+}
+
+func init() {
+	RootCmd.AddCommand(boxInfoCmd)
+}
+
+func boxInfo(_ *cobra.Command, _ []string) error {
+	c := clientLogin()
+	f := fritz.NewInternal(c)
+	info, err := f.BoxInfo()
+	assertNoErr(err, "cannot obtain FRITZ!Box data")
+	printBoxInfo(info)
+	return nil
+}
+
+func printBoxInfo(boxData *fritz.BoxData) {
+	fmt.Printf("%s %s\n", console.Cyan("Model:    "), boxData.Model)
+	fmt.Printf("%s %s\n", console.Cyan("Firmware: "), boxData.FirmwareVersion)
+	fmt.Printf("%s %s\n", console.Cyan("Running:  "), boxData.Runtime)
+}
diff --git a/console/colors.go b/console/colors.go
new file mode 100644
index 0000000..02e3115
--- /dev/null
+++ b/console/colors.go
@@ -0,0 +1,14 @@
+package console
+
+import "github.com/fatih/color"
+
+var (
+	// Red can be used as Sprintf, where the output it wrapped in escape characters which will render the text red in terminals.
+	Red = color.New(color.Bold, color.FgRed).SprintfFunc()
+	// Green can be used as Sprintf, where the output it wrapped in escape characters which will render the text green in terminals.
+	Green = color.New(color.Bold, color.FgGreen).SprintfFunc()
+	// Yellow can be used as Sprintf, where the output it wrapped in escape characters which will render the text yellow in terminals.
+	Yellow = color.New(color.Bold, color.FgYellow).SprintfFunc()
+	// Cyan can be used as Sprintf, where the output it wrapped in escape characters which will render the text cyan in terminals.
+	Cyan = color.New(color.FgCyan).SprintfFunc()
+)
diff --git a/console/survey.go b/console/survey.go
index a732583..56ecac7 100644
--- a/console/survey.go
+++ b/console/survey.go
@@ -98,9 +98,8 @@ func (s *Survey) writeTo(m map[string]interface{}, v interface{}) error {
 
 func (q *Question) prompt(w io.Writer) {
 	hint := q.defaultHint()
-	cyan := color.New(color.FgCyan).SprintfFunc()
 	bold := color.New(color.Bold).SprintfFunc()
-	fmt.Fprintf(w, "%s %s%s: ", cyan("?"), bold("%s", q.Text), hint)
+	fmt.Fprintf(w, "%s %s%s: ", Cyan("?"), bold("%s", q.Text), hint)
 }
 
 func (q *Question) defaultHint() string {
diff --git a/console/symbols.go b/console/symbols.go
index 2189818..07fe950 100644
--- a/console/symbols.go
+++ b/console/symbols.go
@@ -2,8 +2,6 @@ package console
 
 import (
 	"strings"
-
-	"github.com/fatih/color"
 )
 
 const (
@@ -12,15 +10,6 @@ const (
 	checkQ = "?"
 )
 
-var (
-	// Red can be used as Sprintf, where the output it wrapped in escape characters which will render the text red in terminals.
-	Red = color.New(color.Bold, color.FgRed).SprintfFunc()
-	// Green can be used as Sprintf, where the output it wrapped in escape characters which will render the text green in terminals.
-	Green = color.New(color.Bold, color.FgGreen).SprintfFunc()
-	// Yellow can be used as Sprintf, where the output it wrapped in escape characters which will render the text yellow in terminals.
-	Yellow = color.New(color.Bold, color.FgYellow).SprintfFunc()
-)
-
 // Checkmark type is a string with some functions attached.
 type Checkmark string
 
diff --git a/fritz/api_internal.go b/fritz/api_internal.go
index 4a408c8..f419412 100644
--- a/fritz/api_internal.go
+++ b/fritz/api_internal.go
@@ -2,6 +2,7 @@ package fritz
 
 import (
 	"github.com/bpicode/fritzctl/httpread"
+	"github.com/pkg/errors"
 )
 
 // Internal exposes Fritz!Box internal and undocumented API.
@@ -9,6 +10,7 @@ type Internal interface {
 	ListLanDevices() (*LanDevices, error)
 	ListLogs() (*MessageLog, error)
 	InternetStats() (*TrafficMonitoringData, error)
+	BoxInfo() (*BoxData, error)
 }
 
 // NewInternal creates a Fritz/internal API from a given client.
@@ -54,6 +56,19 @@ func (i *internal) InternetStats() (*TrafficMonitoringData, error) {
 	return &data[0], err
 }
 
+// BoxInfo queries metadata from the FRITZ!Box. Data is drawn from: https://fritz.box/cgi-bin/system_status.
+func (i *internal) BoxInfo() (*BoxData, error) {
+	url := i.systemStatus().build()
+	h := struct {
+		Body string `xml:"body"`
+	}{}
+	err := httpread.XML(i.client.getf(url), &h)
+	if err != nil {
+		return nil, errors.Wrap(err, "could not obtain raw system status data")
+	}
+	return boxDataParser{}.parse(h.Body), nil
+}
+
 func (i *internal) query() fritzURLBuilder {
 	return i.client.query().path(queryURI)
 }
@@ -61,3 +76,7 @@ func (i *internal) query() fritzURLBuilder {
 func (i *internal) inetStat() fritzURLBuilder {
 	return i.client.query().path(inetStatURI)
 }
+
+func (i *internal) systemStatus() fritzURLBuilder {
+	return i.client.query().path(systemStatusURI)
+}
diff --git a/fritz/box_data.go b/fritz/box_data.go
new file mode 100644
index 0000000..67ca150
--- /dev/null
+++ b/fritz/box_data.go
@@ -0,0 +1,113 @@
+package fritz
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/bpicode/fritzctl/stringutils"
+)
+
+// BoxData contains runtime information of the FRITZ!Box.
+// codebeat:disable[TOO_MANY_IVARS]
+type BoxData struct {
+	Model           Model
+	FirmwareVersion FirmwareVersion
+	Runtime         Runtime
+	Hash            string
+	Status          string
+}
+
+// Model contains information about the type of the box.
+type Model struct {
+	Name     string
+	Annex    string
+	Branding string
+}
+
+// FirmwareVersion represent the FRITZ!OS version.
+type FirmwareVersion struct {
+	Image             string
+	OsVersionMajor    string
+	OsVersionMinor    string
+	OsVersionRevision string
+}
+
+// Runtime contains data on how long the FRITZ!Box has been running.
+type Runtime struct {
+	Hours   uint64
+	Days    uint64
+	Months  uint64
+	Years   uint64
+	Reboots uint64
+}
+
+// codebeat:enable[TOO_MANY_IVARS]
+
+// String returns a textual representation of Model.
+func (m Model) String() string {
+	return fmt.Sprintf("%s, ADSL standard '%s', branded as '%s'", m.Name, m.Annex,
+		stringutils.DefaultIfEmpty(m.Branding, "unknown"))
+}
+
+// String returns a textual representation of FirmwareVersion.
+func (v FirmwareVersion) String() string {
+	return fmt.Sprintf("FRITZ!OS %s.%s (%s.%s.%s revision %s)", v.OsVersionMajor, v.OsVersionMinor,
+		v.Image, v.OsVersionMajor, v.OsVersionMinor, stringutils.DefaultIfEmpty(v.OsVersionRevision, "unknown"))
+}
+
+// String returns a textual representation of Runtime.
+func (r Runtime) String() string {
+	return fmt.Sprintf("%d years, %d months, %d days, %d hours, %d reboots", r.Years, r.Months, r.Days, r.Hours, r.Reboots)
+}
+
+type boxDataParser struct {
+}
+
+func (p boxDataParser) parse(raw string) *BoxData {
+	trimmed := strings.TrimSpace(raw)
+	tokens := strings.Split(trimmed, "-")
+	var tenTokens [10]string
+	copy(tenTokens[:], tokens)
+	if len(tokens) > 9 {
+		tenTokens[9] = strings.Join(tokens[9:], "-")
+	}
+	return p.parseTokens(tenTokens)
+}
+
+func (p boxDataParser) parseTokens(tokens [10]string) *BoxData {
+	var data BoxData
+	data.Model = p.model(tokens[0], tokens[1], tokens[9])
+	data.Runtime = p.runningFor(tokens[2], tokens[3])
+	data.Hash = fmt.Sprintf("%s-%s", tokens[4], tokens[5])
+	data.Status = tokens[6]
+	data.FirmwareVersion = p.firmwareVersion(tokens[7], tokens[8])
+	return &data
+}
+
+func (p boxDataParser) runningFor(firstToken, secondToken string) Runtime {
+	var r Runtime
+	r.Hours, _ = strconv.ParseUint(firstToken[:2], 10, 64)
+	r.Days, _ = strconv.ParseUint(firstToken[2:4], 10, 64)
+	r.Months, _ = strconv.ParseUint(firstToken[4:], 10, 64)
+	r.Years, _ = strconv.ParseUint(secondToken[:2], 10, 64)
+	r.Reboots, _ = strconv.ParseUint(secondToken[2:], 10, 64)
+	return r
+
+}
+
+func (p boxDataParser) firmwareVersion(version, revision string) FirmwareVersion {
+	return FirmwareVersion{
+		Image:             version[0:3],
+		OsVersionMajor:    version[3:5],
+		OsVersionMinor:    version[5:],
+		OsVersionRevision: revision,
+	}
+}
+func (p boxDataParser) model(name, annex, branding string) Model {
+	return Model{
+		Name:     name,
+		Annex:    annex,
+		Branding: branding,
+	}
+}
diff --git a/fritz/fritzapi_constants.go b/fritz/fritzapi_constants.go
index ae3e482..521e7f7 100644
--- a/fritz/fritzapi_constants.go
+++ b/fritz/fritzapi_constants.go
@@ -14,6 +14,7 @@ const (
 	queryURI          = "/query.lua"
 	inetStatURI       = "/internet/inetstat_monitor.lua"
 	phoneListURI      = "/fon_num/foncalls_list.lua"
+	systemStatusURI   = "/cgi-bin/system_status"
 )
 
 type fritzURLBuilder interface {
diff --git a/mock/fritzbox_mock.go b/mock/fritzbox_mock.go
index 2cfdb50..5e21c6c 100644
--- a/mock/fritzbox_mock.go
+++ b/mock/fritzbox_mock.go
@@ -20,6 +20,7 @@ type Fritz struct {
 	LanDevices             string
 	InetStats              string
 	PhoneCalls             string
+	SystemStatus           string
 	Server                 *httptest.Server
 }
 
@@ -35,6 +36,7 @@ func New() *Fritz {
 		LanDevices:             "../mock/landevices.json",
 		InetStats:              "../mock/traffic.json",
 		PhoneCalls:             "../mock/calls.csv",
+		SystemStatus:           "../mock/system_status.html",
 	}
 }
 
@@ -64,6 +66,7 @@ func (f *Fritz) fritzRoutes() *httprouter.Router {
 	router.GET("/query.lua", f.queryHandler)
 	router.GET("/internet/inetstat_monitor.lua", f.inetStatHandler)
 	router.GET("/fon_num/foncalls_list.lua", f.phoneCallsHandler)
+	router.GET("/cgi-bin/system_status", f.systemStatusHandler)
 	return router
 }
 
@@ -129,3 +132,7 @@ func (f *Fritz) preProcess(w http.ResponseWriter, r *http.Request) bool {
 func (f *Fritz) phoneCallsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
 	f.writeFromFs(w, f.PhoneCalls)
 }
+
+func (f *Fritz) systemStatusHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	f.writeFromFs(w, f.SystemStatus)
+}
diff --git a/mock/system_status.html b/mock/system_status.html
new file mode 100644
index 0000000..6099c22
--- /dev/null
+++ b/mock/system_status.html
@@ -0,0 +1,1 @@
+<html><body>FRITZ!Box 7490-B-050103-010027-111111-222222-333333-1130692-47565-avm</body></html>
\ No newline at end of file


Test patch:
diff --git a/cmd/commands_test.go b/cmd/commands_test.go
index c056939..7403a19 100644
--- a/cmd/commands_test.go
+++ b/cmd/commands_test.go
@@ -38,7 +38,9 @@ func TestCommands(t *testing.T) {
 		{cmd: listCallsCmd, args: []string{}, srv: mock.New().UnstartedServer()},
 		{cmd: listSwitchesCmd, srv: mock.New().UnstartedServer()},
 		{cmd: listThermostatsCmd, srv: mock.New().UnstartedServer()},
+		{cmd: listThermostatsCmd, srv: mock.New().UnstartedServer()},
 		{cmd: docManCmd, srv: mock.New().UnstartedServer()},
+		{cmd: boxInfoCmd, srv: mock.New().UnstartedServer()},
 	}
 	for _, testCase := range testCases {
 		t.Run(fmt.Sprintf("Test run command %s", testCase.cmd.Name()), func(t *testing.T) {
diff --git a/fritz/api_internal_test.go b/fritz/api_internal_test.go
index 96002bc..cdb700e 100644
--- a/fritz/api_internal_test.go
+++ b/fritz/api_internal_test.go
@@ -19,29 +19,32 @@ func TestInternalFritzAPI(t *testing.T) {
 		{testListLanDevices},
 		{testListLogs},
 		{testInetStats},
+		{testBoxInfo},
 	}
 	for _, testCase := range testCases {
 		t.Run(fmt.Sprintf("Test aha 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)
-
-			internal := NewInternal(client)
+			internal := setUpClient(t, srv)
 			assert.NotNil(t, internal)
 			testCase.tc(t, internal)
 		})
 	}
 }
 
+func setUpClient(t *testing.T, srv *mock.Fritz) Internal {
+	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)
+	internal := NewInternal(client)
+	return internal
+}
+
 func testInetStats(t *testing.T, i Internal) {
 	_, err := i.InternetStats()
 	assert.NoError(t, err)
@@ -64,3 +67,20 @@ func testListLogs(t *testing.T, i Internal) {
 		assert.Len(t, m, 3)
 	}
 }
+
+func testBoxInfo(t *testing.T, i Internal) {
+	data, err := i.BoxInfo()
+	assert.NoError(t, err)
+	assert.NotZero(t, data)
+}
+
+// TestInternal_BoxInfo_WithError test the error path of BoxInfo.
+func TestInternal_BoxInfo_WithError(t *testing.T) {
+	m := mock.New()
+	m.SystemStatus = "/path/to/nonexistent/file/jsafbjsabfjasb.html"
+	srv := m.Start()
+	defer srv.Close()
+	internal := setUpClient(t, srv)
+	_, err := internal.BoxInfo()
+	assert.Error(t, err)
+}
diff --git a/fritz/box_data_test.go b/fritz/box_data_test.go
new file mode 100644
index 0000000..e2410e5
--- /dev/null
+++ b/fritz/box_data_test.go
@@ -0,0 +1,39 @@
+package fritz
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_boxDataParser_parse(t *testing.T) {
+	tests := []struct {
+		name string
+		arg  string
+	}{
+		{name: "Fon ata 1020", arg: "FRITZ!Box Fon ata 1020-B-060102-040410-262336-724176-787902-110401-2956-avme-en"},
+		{name: "Fon WLAN UI", arg: "FRITZ!Box Fon WLAN (UI)-B-070608-050527-151063-152677-787902-080434-7804-1und1"},
+		{name: "Fon WLAN 7170", arg: "FRITZ!Box Fon WLAN 7170-B-171008-000028-457563-147110-787902-290487-19985-avm"},
+		{name: "Fon WLAN 7170", arg: "FRITZ!Box Fon WLAN 7170 (UI)-B-171607-041025-521747-370532-147902-290486-19138-1und1"},
+		{name: "Fon WLAN 7170", arg: "FRITZ!Box Fon WLAN 7170 Annex A-A-042602-030325-402401-042265-787902-580482"},
+		{name: "Fon WLAN 7270", arg: "FRITZ!Box Fon WLAN 7270-B-111700-030716-306463-160202-787902-540480-15918-avm"},
+		{name: "Fon WLAN 7270", arg: "FRITZ!Box Fon WLAN 7270 v2-B-071710-020026-055200-026256-147902-540504-20260-avme-en"},
+		{name: "Fon WLAN 7340", arg: "FRITZ!Box Fon WLAN 7340-B-072706-000217-533416-737311-147902-990505-20608-avme-en"},
+		{name: "Fon WLAN 7390", arg: "FRITZ!Box Fon WLAN 7390-B-161001-000109-670300-000324-787902-840507-21400-avm"},
+		{name: "Fon WLAN 7390", arg: "FRITZ!Box Fon WLAN 7390 (UI)-B-171408-010206-436146-332654-147902-840505-20359-1und1"},
+		{name: "Fon WLAN 7390", arg: "FRITZ!Box Fon WLAN 7390-A-091301-000006-002567-355146-787902-840505-20608-avme-de"},
+		{name: "Repeater NG", arg: "FRITZ!WLAN Repeater N/G-B-152108-020222-445034-614506-787902-680486-20648-avm"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			p := boxDataParser{}
+			data := p.parse(tt.arg)
+			assert.NotZero(t, data)
+			fmt.Println("Model", data.Model)
+			fmt.Println("Version", data.FirmwareVersion)
+			fmt.Println("Runtime", data.Runtime)
+			fmt.Println("----")
+		})
+	}
+}
diff --git a/mock/fritzbox_mock_test.go b/mock/fritzbox_mock_test.go
index 1a1ec94..b2702a6 100644
--- a/mock/fritzbox_mock_test.go
+++ b/mock/fritzbox_mock_test.go
@@ -106,6 +106,7 @@ func TestResponseWhenBackendFileNotFound(t *testing.T) {
 	assert5xxResponse(t, r)
 }
 
+// TestPhoneCalls tests the mocked fritz server.
 func TestPhoneCalls(t *testing.T) {
 	fritz := New()
 	fritz.Start()
@@ -115,6 +116,15 @@ func TestPhoneCalls(t *testing.T) {
 	assert2xxResponse(t, r)
 }
 
+// TestSystemStatus tests the mocked fritz server.
+func TestSystemStatus(t *testing.T) {
+	fritz := New()
+	fritz.Start()
+	defer fritz.Close()
+	r, _ := (&http.Client{}).Get(fritz.Server.URL + "/cgi-bin/system_status")
+	assert2xxResponse(t, r)
+}
+
 func assert2xxResponse(t *testing.T, r *http.Response) {
 	assert.True(t, r.StatusCode >= 200)
 	assert.True(t, r.StatusCode < 300)