Repository: JanDeDobbeleer/oh-my-posh
Pull request #1432 (2021-12-14 14:20:01)

feat(brewfather): brewfather segment work squashed
segment code and docs
modification to doGet to support request modifier funcs

### Prerequisites

- [X] I have read and understood the `CONTRIBUTING` guide
- [X] The commit message follows the [conventional commits][cc] guidelines
- [X] Tests for the changes have been added (for bug fixes/features)
- [X] Docs have been added / updated (for bug fixes/features)

feat(brewfather): show brew batch information from Brewfather's REST API

new segment code and docs
additional functionality on env.doGet to add request modifiers

<!---

Tips:

If you're not comfortable with working with Git, we're working a guide (https://ohmyposh.dev/docs/contributing_git) to help you out.
Oh My Posh advises GitKraken (https://www.gitkraken.com/invite/nQmDPR9D) as your preferred cross platform Git GUI power tool.

-->

[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary



Patch:
diff --git a/docs/docs/segment-brewfather.md b/docs/docs/segment-brewfather.md
new file mode 100644
index 00000000..4449a578
--- /dev/null
+++ b/docs/docs/segment-brewfather.md
@@ -0,0 +1,136 @@
+---
+id: brewfather
+title: Brewfather
+sidebar_label: Brewfather
+---
+
+## What
+
+Calling all brewers!  Keep up-to-date with the status of your [Brewfather][brewfather] batch directly in your
+ commandline prompt using the brewfather segment!
+
+ You will need your User ID and API Key as generated in
+ Brewfather's Settings screen, enabled with **batches.read** and **recipes.read** scopes.
+
+## Sample Configuration
+
+This example uses the default segment template to show a rendition of detail appropriate to the status of the batch
+
+Additionally, the background of the segment will turn red if the latest reading is over 4 hours old - possibly helping indicate
+an issue if, for example there is a Tilt or similar device that is supposed to be logging to Brewfather every 15 minutes.
+
+NOTE: Temperature units are in degrees C and specific gravity is expressed as `1.XYZ` values.
+
+```json
+{
+    "type":"brewfather",
+    "style": "powerline",
+    "powerline_symbol": "\uE0B0",
+    "foreground": "#ffffff",
+    "background": "#33158A",
+    "background_templates": [
+        "{{ if and (.Reading) (eq .Status \"Fermenting\") (gt .ReadingAge 4) }}#cc1515{{end}}"
+    ],
+    "properties": {
+        "user_id":"abcdefg123456",
+        "api_key":"qrstuvw78910",
+        "batch_id":"hijklmno098765",
+    }
+},
+```
+
+## Properties
+
+- user_id: `string` - as provided by Brewfather's Generate API Key screen.
+- api_key: `string` - as provided by Brewfather's Generate API Key screen.
+- batch_id: `string` - Get this by navigating to the desired batch on the brewfather website,
+the batch id is at the end of the URL in the address bar.
+- http_timeout: `int` in milliseconds - How long to wait for the Brewfather service to answer the request.  Default 2 seconds.
+- template: `string` - a go [text/template][go-text-template] template extended
+with [sprig][sprig] utilizing the properties below.
+- cache_timeout: `int` in minutes - How long to wait before updating the data from Brewfather.  Default is 5 minutes.
+- day_icon: `string` - icon or letter to use to indicate days.  Default is "d".
+
+You can override the icons for temperature trend as used by template property `.TemperatureTrendIcon` with:
+
+- doubleup_icon - for increases of more than 4°C, default is ↑↑
+- singleup_icon - increase 2-4°C, default is ↑
+- fortyfiveup_icon - increase 0.5-2°C, default is ↗
+- flat_icon -change less than 0.5°C, default is →
+- fortyfivedown_icon - decrease 0.5-2°C, default is ↘
+- singledown_icon - decrease 2-4°C, default is ↓
+- doubledown_icon - decrease more than 4°C, default is ↓↓
+
+You can override the default icons for batch status as used by template property `.StatusIcon` with:
+
+- planning_status_icon
+- brewing_status_icon
+- fermenting_status_icon
+- conditioning_status_icon
+- completed_status_icon
+- archived_status_icon
+
+## Template Properties
+
+Commonly used fields
+
+- .Status: `string` - One of "Planning", "Brewing", "Fermenting", "Conditioning", "Completed" or "Archived"
+- .StatusIcon `string` - Icon representing above stats.  Can be overridden with properties shown above
+- .TemperatureTrendIcon `string` - Icon showing temperature trend based on latest and previous reading
+- .DaysFermenting `int` - days since start of fermentation
+- .DaysBottled `int` - days since bottled/kegged
+- .DaysBottledOrFermented `int` - one of the above, chosen automatically based on batch status
+- .Recipe.Name: `string` - The recipe being brewed in this batch
+- .MeasuredAbv: `float` - The ABV for the batch - either estimated from recipe or calculated from entered OG and FG values
+- .ReadingAge `int` - age in hours of most recent reading or -1 if there are no readings available
+  
+.Reading contains the most recent data from devices or manual entry as visible on the Brewfather's batch Readings graph.
+If there are no readings available, .Reading will be null.
+
+- .Reading.Gravity: `float` - specific gravity (in decimal point format)
+- .Reading.Temperature `float` - temperature in °C
+- .Reading.Time `int` - unix timestamp of reading
+- .Reading.Comment `string` - comment attached to this reading
+- .Reading.DeviceType `string` - source of the reading, e.g. "Tilt"
+- .Reading.DeviceID `string` - id of the device, e.g. "PINK"
+  
+Additional template properties
+
+- .MeasuredOg: `float` - The OG for the batch as manually entered into Brewfather
+- .MeasuredFg: `float` -The FG for the batch as manually entered into Brewfather
+- .BrewDate: `int` - The unix timestamp of the brew day
+- .FermentStartDate: `int` The unix timestamp when fermentation was started
+- .BottlingDate: `time` - The unix timestamp when bottled/kegged
+- .TemperatureTrend `float` - The difference between the most recent and previous temperature in °C
+- .DayIcon `string` - given by "day_icon", or "d" by default
+
+Hyperlink support
+
+- .URL `string` - the URL for the batch in the Brewfather app.  You can use this to add a hyperlink to the segment
+if you are using a terminal that supports it and the segment has `"enable_hyperlink":true` in it's properties.  `.DefaultString`
+has this by default.
+
+  Hyperlink formatting example
+
+  ````json
+  {
+    // General format: [Text](Url)
+    "template":"[{{.StatusIcon}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}d{{end}} {{.Recipe.Name}}]({{.URL}})"
+  }
+
+  ````
+
+### Advanced Templating
+
+The built in template will provides key useful information.  However, you can use the properties about the batch
+to build your own.  For reference, the built-in template looks like this:
+
+  ````json
+  {
+    "template":"{{.StatusIcon}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}{{.DayIcon}} {{end}}[{{.Recipe.Name}}]({{.URL}}) {{printf \"%.1f\" .MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}}: {{printf \"%.3f\" .Reading.Gravity}} {{.Reading.Temperature}}\u00b0 {{.TemperatureTrendIcon}}{{end}}"
+  }
+  ````
+
+[go-text-template]: https://golang.org/pkg/text/template/
+[sprig]: https://masterminds.github.io/sprig/
+[brewfather]: http://brewfather.app
diff --git a/src/environment.go b/src/environment.go
index 750ab6a5..61f7621c 100644
--- a/src/environment.go
+++ b/src/environment.go
@@ -56,6 +56,8 @@ type cache interface {
 	set(key, value string, ttl int)
 }
 
+type HTTPRequestModifier func(request *http.Request)
+
 type windowsRegistryValueType int
 
 const (
@@ -96,7 +98,7 @@ type environmentInfo interface {
 	getShellName() string
 	getWindowTitle(imageName, windowTitleRegex string) (string, error)
 	getWindowsRegistryKeyValue(path string) (*windowsRegistryValue, error)
-	doGet(url string, timeout int) ([]byte, error)
+	doGet(url string, timeout int, requestModifiers ...HTTPRequestModifier) ([]byte, error)
 	hasParentFilePath(path string) (fileInfo *fileInfo, err error)
 	isWsl() bool
 	stackCount() int
@@ -480,7 +482,7 @@ func (env *environment) getShellName() string {
 	return *env.args.Shell
 }
 
-func (env *environment) doGet(url string, timeout int) ([]byte, error) {
+func (env *environment) doGet(url string, timeout int, requestModifiers ...HTTPRequestModifier) ([]byte, error) {
 	defer env.trace(time.Now(), "doGet", url)
 	ctx, cncl := context.WithTimeout(context.Background(), time.Millisecond*time.Duration(timeout))
 	defer cncl()
@@ -488,6 +490,9 @@ func (env *environment) doGet(url string, timeout int) ([]byte, error) {
 	if err != nil {
 		return nil, err
 	}
+	for _, modifier := range requestModifiers {
+		modifier(request)
+	}
 	response, err := client.Do(request)
 	if err != nil {
 		env.log(Error, "doGet", err.Error())
diff --git a/src/segment.go b/src/segment.go
index 0aedb5d4..2859af33 100644
--- a/src/segment.go
+++ b/src/segment.go
@@ -139,6 +139,8 @@ const (
 	WiFi SegmentType = "wifi"
 	// WinReg queries the Windows registry.
 	WinReg SegmentType = "winreg"
+	// Brewfather segment
+	BrewFather SegmentType = "brewfather"
 )
 
 func (segment *Segment) string() string {
@@ -271,6 +273,7 @@ func (segment *Segment) mapSegmentWithWriter(env environmentInfo) error {
 		Wakatime:      &wakatime{},
 		WiFi:          &wifi{},
 		WinReg:        &winreg{},
+		BrewFather:    &brewfather{},
 	}
 	if segment.Properties == nil {
 		segment.Properties = make(properties)
diff --git a/src/segment_brewfather.go b/src/segment_brewfather.go
new file mode 100644
index 00000000..85f2ab41
--- /dev/null
+++ b/src/segment_brewfather.go
@@ -0,0 +1,319 @@
+package main
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"sort"
+	"time"
+)
+
+// segment struct, makes templating easier
+type brewfather struct {
+	props properties
+	env   environmentInfo
+
+	Batch
+	TemperatureTrendIcon string
+	StatusIcon           string
+	DayIcon              string // populated from day_icon for use in template
+
+	ReadingAge             int // age in hours of the most recent reading included in the batch, -1 if none
+	DaysFermenting         uint
+	DaysBottled            uint
+	DaysBottledOrFermented *uint // help avoid chronic template logic - code will point this to one of above or be nil depending on status
+
+	URL string // URL of batch page to open if hyperlink enabled on the segment and URL formatting used in template: [name](link)
+}
+
+const (
+	BFUserID  Property = "user_id"
+	BFAPIKey  Property = "api_key"
+	BFBatchID Property = "batch_id"
+
+	BFDoubleUpIcon      Property = "doubleup_icon"
+	BFSingleUpIcon      Property = "singleup_icon"
+	BFFortyFiveUpIcon   Property = "fortyfiveup_icon"
+	BFFlatIcon          Property = "flat_icon"
+	BFFortyFiveDownIcon Property = "fortyfivedown_icon"
+	BFSingleDownIcon    Property = "singledown_icon"
+	BFDoubleDownIcon    Property = "doubledown_icon"
+
+	BFPlanningStatusIcon     Property = "planning_status_icon"
+	BFBrewingStatusIcon      Property = "brewing_status_icon"
+	BFFermentingStatusIcon   Property = "fermenting_status_icon"
+	BFConditioningStatusIcon Property = "conditioning_status_icon"
+	BFCompletedStatusIcon    Property = "completed_status_icon"
+	BFArchivedStatusIcon     Property = "archived_status_icon"
+
+	BFDayIcon Property = "day_icon"
+
+	BFCacheTimeout Property = "cache_timeout"
+
+	DefaultTemplate string = "{{.StatusIcon}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}{{.DayIcon}} {{end}}[{{.Recipe.Name}}]({{.URL}})" +
+		" {{printf \"%.1f\" .MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}} " +
+		"{{printf \"%.3f\" .Reading.Gravity}} {{.Reading.Temperature}}\u00b0 {{.TemperatureTrendIcon}}{{end}}"
+
+	BFStatusPlanning     string = "Planning"
+	BFStatusBrewing      string = "Brewing"
+	BFStatusFermenting   string = "Fermenting"
+	BFStatusConditioning string = "Conditioning"
+	BFStatusCompleted    string = "Completed"
+	BFStatusArchived     string = "Archived"
+)
+
+// Returned from https://api.brewfather.app/v1/batches/batch_id/readings
+type BatchReading struct {
+	Comment     string  `json:"comment"`
+	Gravity     float64 `json:"sg"`
+	DeviceType  string  `json:"type"`
+	DeviceID    string  `json:"id"`
+	Temperature float64 `json:"temp"`      // celsius - need to add F conversion
+	Timepoint   int64   `json:"timepoint"` // << check what these are...
+	Time        int64   `json:"time"`      // <<
+}
+type Batch struct {
+	// Json tagged values returned from https://api.brewfather.app/v1/batches/batch_id
+	Status string `json:"status"`
+	Recipe struct {
+		Name string `json:"name"`
+	} `json:"recipe"`
+	BrewDate         int64 `json:"brewDate"`
+	FermentStartDate int64 `json:"fermentationStartDate"`
+	BottlingDate     int64 `json:"bottlingDate"`
+
+	MeasuredOg  float64 `json:"measuredOg"`
+	MeasuredFg  float64 `json:"measuredFg"`
+	MeasuredAbv float64 `json:"measuredAbv"`
+
+	// copy of the latest BatchReading in here.
+	Reading *BatchReading
+
+	// Calculated values we need to cache because they require the rest query to reproduce
+	TemperatureTrend float64 // diff between this and last, short term trend
+}
+
+func (bf *brewfather) enabled() bool {
+	data, err := bf.getResult()
+	if err != nil {
+		return false
+	}
+	bf.Batch = *data
+
+	if bf.Batch.Reading != nil {
+		readingDate := time.UnixMilli(bf.Batch.Reading.Time)
+		bf.ReadingAge = int(time.Since(readingDate).Hours())
+	} else {
+		bf.ReadingAge = -1
+	}
+
+	bf.TemperatureTrendIcon = bf.getTrendIcon(bf.TemperatureTrend)
+	bf.StatusIcon = bf.getBatchStatusIcon(data.Status)
+
+	fermStartDate := time.UnixMilli(bf.Batch.FermentStartDate)
+	bottlingDate := time.UnixMilli(bf.Batch.BottlingDate)
+
+	switch bf.Batch.Status {
+	case BFStatusFermenting:
+		// in the fermenter now, so relative to today.
+		bf.DaysFermenting = uint(time.Since(fermStartDate).Hours() / 24)
+		bf.DaysBottled = 0
+		bf.DaysBottledOrFermented = &bf.DaysFermenting
+	case BFStatusConditioning, BFStatusCompleted, BFStatusArchived:
+		bf.DaysFermenting = uint(bottlingDate.Sub(fermStartDate).Hours() / 24)
+		bf.DaysBottled = uint(time.Since(bottlingDate).Hours() / 24)
+		bf.DaysBottledOrFermented = &bf.DaysBottled
+	default:
+		bf.DaysFermenting = 0
+		bf.DaysBottled = 0
+		bf.DaysBottledOrFermented = nil
+	}
+
+	// URL property set to weblink to the full batch page
+	batchID := bf.props.getString(BFBatchID, "")
+	if len(batchID) > 0 {
+		bf.URL = fmt.Sprintf("https://web.brewfather.app/tabs/batches/batch/%s", batchID)
+	}
+
+	bf.DayIcon = bf.props.getString(BFDayIcon, "d")
+
+	return true
+}
+
+func (bf *brewfather) getTrendIcon(trend float64) string {
+	// Not a fan of this logic - wondering if Go lets us do something cleaner...
+	if trend >= 0 {
+		if trend > 4 {
+			return bf.props.getString(BFDoubleUpIcon, "↑↑")
+		}
+
+		if trend > 2 {
+			return bf.props.getString(BFSingleUpIcon, "↑")
+		}
+
+		if trend > 0.5 {
+			return bf.props.getString(BFFortyFiveUpIcon, "↗")
+		}
+
+		return bf.props.getString(BFFlatIcon, "→")
+	}
+
+	if trend < -4 {
+		return bf.props.getString(BFDoubleDownIcon, "↓↓")
+	}
+
+	if trend < -2 {
+		return bf.props.getString(BFSingleDownIcon, "↓")
+	}
+
+	if trend < -0.5 {
+		return bf.props.getString(BFFortyFiveDownIcon, "↘")
+	}
+
+	return bf.props.getString(BFFlatIcon, "→")
+}
+
+func (bf *brewfather) getBatchStatusIcon(batchStatus string) string {
+	switch batchStatus {
+	case BFStatusPlanning:
+		return bf.props.getString(BFPlanningStatusIcon, "\uF8EA")
+	case BFStatusBrewing:
+		return bf.props.getString(BFBrewingStatusIcon, "\uF7DE")
+	case BFStatusFermenting:
+		return bf.props.getString(BFFermentingStatusIcon, "\uF499")
+	case BFStatusConditioning:
+		return bf.props.getString(BFConditioningStatusIcon, "\uE372")
+	case BFStatusCompleted:
+		return bf.props.getString(BFCompletedStatusIcon, "\uF7A5")
+	case BFStatusArchived:
+		return bf.props.getString(BFArchivedStatusIcon, "\uF187")
+	default:
+		return ""
+	}
+}
+
+func (bf *brewfather) string() string {
+	segmentTemplate := bf.props.getString(SegmentTemplate, DefaultTemplate)
+	template := &textTemplate{
+		Template: segmentTemplate,
+		Context:  bf,
+		Env:      bf.env,
+	}
+	text, err := template.render()
+	if err != nil {
+		return err.Error()
+	}
+
+	return text
+}
+
+func (bf *brewfather) getResult() (*Batch, error) {
+	getFromCache := func(key string) (*Batch, error) {
+		val, found := bf.env.cache().get(key)
+		// we got something from the cache
+		if found {
+			var result Batch
+			err := json.Unmarshal([]byte(val), &result)
+			if err == nil {
+				return &result, nil
+			}
+		}
+		return nil, errors.New("no data in cache")
+	}
+
+	putToCache := func(key string, batch *Batch, cacheTimeout int) error {
+		cacheJSON, err := json.Marshal(batch)
+		if err != nil {
+			return err
+		}
+
+		bf.env.cache().set(key, string(cacheJSON), cacheTimeout)
+
+		return nil
+	}
+
+	userID := bf.props.getString(BFUserID, "")
+	if len(userID) == 0 {
+		return nil, errors.New("missing Brewfather user id (user_id)")
+	}
+
+	apiKey := bf.props.getString(BFAPIKey, "")
+	if len(apiKey) == 0 {
+		return nil, errors.New("missing Brewfather api key (api_key)")
+	}
+
+	batchID := bf.props.getString(BFBatchID, "")
+	if len(batchID) == 0 {
+		return nil, errors.New("missing Brewfather batch id (batch_id)")
+	}
+
+	authString := fmt.Sprintf("%s:%s", userID, apiKey)
+	authStringb64 := base64.StdEncoding.EncodeToString([]byte(authString))
+	authHeader := fmt.Sprintf("Basic %s", authStringb64)
+	batchURL := fmt.Sprintf("https://api.brewfather.app/v1/batches/%s", batchID)
+	batchReadingsURL := fmt.Sprintf("https://api.brewfather.app/v1/batches/%s/readings", batchID)
+
+	httpTimeout := bf.props.getInt(HTTPTimeout, DefaultHTTPTimeout)
+	cacheTimeout := bf.props.getInt(BFCacheTimeout, 5)
+
+	if cacheTimeout > 0 {
+		if data, err := getFromCache(batchURL); err == nil {
+			return data, nil
+		}
+	}
+
+	// batch
+	addAuthHeader := func(request *http.Request) {
+		request.Header.Add("authorization", authHeader)
+	}
+	body, err := bf.env.doGet(batchURL, httpTimeout, addAuthHeader)
+	if err != nil {
+		return nil, err
+	}
+
+	var batch Batch
+	err = json.Unmarshal(body, &batch)
+	if err != nil {
+		return nil, err
+	}
+
+	// readings
+	body, err = bf.env.doGet(batchReadingsURL, httpTimeout, addAuthHeader)
+	if err != nil {
+		return nil, err
+	}
+
+	var arr []*BatchReading
+	err = json.Unmarshal(body, &arr)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(arr) > 0 {
+		// could just take latest reading using their API, but that won't allow us to see trend - get 'em all and sort by time,
+		// using two most recent for trend
+		sort.Slice(arr, func(i, j int) bool {
+			return arr[i].Time > arr[j].Time
+		})
+
+		// Keep the latest one
+		batch.Reading = arr[0]
+
+		if len(arr) > 1 {
+			batch.TemperatureTrend = arr[0].Temperature - arr[1].Temperature
+		}
+	}
+
+	if cacheTimeout > 0 {
+		_ = putToCache(batchURL, &batch, cacheTimeout)
+	}
+
+	return &batch, nil
+}
+
+func (bf *brewfather) init(props properties, env environmentInfo) {
+	bf.props = props
+	bf.env = env
+}
diff --git a/themes/schema.json b/themes/schema.json
index bbca05d0..0fd38cd2 100644
--- a/themes/schema.json
+++ b/themes/schema.json
@@ -1798,6 +1798,50 @@
               }
             }
           }
+        },
+        {
+          "if": {
+            "properties": {
+              "type": { "const": "brewfather" }
+            }
+          },
+          "then": {
+            "title": "Brewfather Batch Status",
+            "description": "https://ohmyposh.dev/docs/brewfather",
+            "properties": {
+              "properties": {
+                "properties": {
+                  "template": {
+                    "$ref": "#/definitions/template"
+                  },
+                  "user_id": {
+                    "type": "string",
+                    "title": "Brewfather UserID (required)",
+                    "description": "Provided by Brewfather's Generate API Key settings option",
+                    "default": ""
+                  },
+                  "api_key": {
+                    "type":"string",
+                    "title":"Brewfather API Key (required)",
+                    "description":"Provided by Brewfather's Generate API Key settings option",
+                    "default":""
+                  },
+                  "batch_id": {
+                    "type":"string",
+                    "title":"ID of the batch in Brewfather (required)",
+                    "description":"At the end of the URL when viewing the batch on the Brewfather site",
+                    "default":""
+                  },
+                  "day_icon": {
+                    "type":"string",
+                    "title":"Icon to use to indicate days",
+                    "description":"Appended to a number to indicate days, e.g. 25d",
+                    "default":"d"
+                  }
+                }
+              }
+            }
+          }
         }
       ]
     }


Test patch:
diff --git a/src/segment_brewfather_test.go b/src/segment_brewfather_test.go
new file mode 100644
index 00000000..55a63630
--- /dev/null
+++ b/src/segment_brewfather_test.go
@@ -0,0 +1,154 @@
+package main
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+const (
+	BFFakeBatchID      = "FAKE"
+	BFBatchURL         = "https://api.brewfather.app/v1/batches/" + BFFakeBatchID
+	BFCacheKey         = BFBatchURL
+	BFBatchReadingsURL = "https://api.brewfather.app/v1/batches/" + BFFakeBatchID + "/readings"
+)
+
+func TestBrewfatherSegment(t *testing.T) {
+	TimeNow := time.Now()
+
+	// Create a fake timeline for the fake json, all in Unix milliseconds, to be used in all fake json responses
+	FakeBrewDate := TimeNow.Add(-time.Hour * 24 * 20)
+	FakeFermentationStartDate := FakeBrewDate.Add(time.Hour * 24)          // 1 day after brew date = 19 days ago
+	FakeReading1Date := FakeFermentationStartDate.Add(time.Minute * 35)    // first reading 35 minutes
+	FakeReading2Date := FakeReading1Date.Add(time.Hour)                    // second reading 1 hour later
+	FakeReading3Date := FakeReading2Date.Add(time.Hour * 3)                // 3 hours after last reading, 454 hours ago
+	FakeBottlingDate := FakeFermentationStartDate.Add(time.Hour * 24 * 14) // 14 days after ferm date = 5 days ago
+
+	FakeBrewDateString := fmt.Sprintf("%d", FakeBrewDate.UnixMilli())
+	FakeFermStartDateString := fmt.Sprintf("%d", FakeFermentationStartDate.UnixMilli())
+	FakeReading1DateString := fmt.Sprintf("%d", FakeReading1Date.UnixMilli())
+	FakeReading2DateString := fmt.Sprintf("%d", FakeReading2Date.UnixMilli())
+	FakeReading3DateString := fmt.Sprintf("%d", FakeReading3Date.UnixMilli())
+	FakeBottlingDateString := fmt.Sprintf("%d", FakeBottlingDate.UnixMilli())
+
+	cases := []struct {
+		Case                      string
+		BatchJSONResponse         string
+		BatchReadingsJSONResponse string
+		ExpectedString            string
+		ExpectedEnabled           bool
+		CacheTimeout              int
+		CacheFoundFail            bool
+		Template                  string
+		Error                     error
+	}{
+		{
+			Case: "Planning Status",
+			BatchJSONResponse: `
+			{"batchNo":18,"status":"Planning","brewDate":` + FakeBrewDateString + `,"bottlingDate":` + FakeBottlingDateString + `,"recipe":{"name":"Fake Beer"},"fermentationStartDate":` + FakeFermStartDateString + `,"name":"Batch","measuredAbv": 1.3}`, // nolint:lll
+			BatchReadingsJSONResponse: `[]`,
+			Template:                  "{{.StatusIcon}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}d {{end}}{{.Recipe.Name}} {{.MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}}: {{.Reading.Gravity}} {{.Reading.Temperature}}\ue33e {{.TemperatureTrendIcon}}{{end}}", //nolint:lll
+			ExpectedString:            " Fake Beer 1.3%",
+			ExpectedEnabled:           true,
+		},
+		{
+			Case: "Brewing Status",
+			BatchJSONResponse: `
+			{"batchNo":18,"status":"Brewing","brewDate":` + FakeBrewDateString + `,"bottlingDate":` + FakeBottlingDateString + `,"recipe":{"name":"Fake Beer"},"fermentationStartDate":` + FakeFermStartDateString + `,"name":"Batch","measuredAbv": 1.3}`, // nolint:lll
+			BatchReadingsJSONResponse: `[]`,
+			Template:                  "{{.StatusIcon}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}d {{end}}{{.Recipe.Name}} {{.MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}}: {{.Reading.Gravity}} {{.Reading.Temperature}}\ue33e {{.TemperatureTrendIcon}}{{end}}", //nolint:lll
+			ExpectedString:            " Fake Beer 1.3%",
+			ExpectedEnabled:           true,
+		},
+		{
+			Case: "Fermenting Status, no readings",
+			BatchJSONResponse: `
+			{"batchNo":18,"status":"Fermenting","brewDate":` + FakeBrewDateString + `,"bottlingDate":` + FakeBottlingDateString + `,"recipe":{"name":"Fake Beer"},"fermentationStartDate":` + FakeFermStartDateString + `,"name":"Batch","measuredAbv": 1.3}`, // nolint:lll
+			BatchReadingsJSONResponse: `[]`,
+			Template:                  "{{.StatusIcon}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}d {{end}}{{.Recipe.Name}} {{.MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}}: {{.Reading.Gravity}} {{.Reading.Temperature}}\ue33e {{.TemperatureTrendIcon}}{{end}}", //nolint:lll
+			ExpectedString:            " 19d Fake Beer 1.3%",
+			ExpectedEnabled:           true,
+		},
+		{
+			Case: "Fermenting Status, one reading",
+			BatchJSONResponse: `
+			{"batchNo":18,"status":"Fermenting","brewDate":` + FakeBrewDateString + `,"bottlingDate":` + FakeBottlingDateString + `,"recipe":{"name":"Fake Beer"},"fermentationStartDate":` + FakeFermStartDateString + `,"name":"Batch","measuredAbv": 1.3}`, // nolint:lll
+			BatchReadingsJSONResponse: `[{"id":"manual","temp":19.5,"comment":"","sg":1.066,"time":` + FakeReading1DateString + `,"type":"manual"}]`,
+			Template:                  "{{.StatusIcon}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}d {{end}}{{.Recipe.Name}} {{.MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}}: {{.Reading.Gravity}} {{.Reading.Temperature}}\ue33e {{.TemperatureTrendIcon}}{{end}}", //nolint:lll
+			ExpectedString:            " 19d Fake Beer 1.3%: 1.066 19.5 →",
+			ExpectedEnabled:           true,
+		},
+		{
+			Case: "Fermenting Status, two readings, temp trending up",
+			BatchJSONResponse: `
+			{"batchNo":18,"status":"Fermenting","brewDate":` + FakeBrewDateString + `,"bottlingDate":` + FakeBottlingDateString + `,"recipe":{"name":"Fake Beer"},"fermentationStartDate":` + FakeFermStartDateString + `,"name":"Batch","measuredAbv": 1.3}`, // nolint:lll
+			BatchReadingsJSONResponse: `[{"id":"manual","temp":19.5,"comment":"","sg":1.066,"time":` + FakeReading1DateString + `,"type":"manual"}, {"id":"manual","temp":21,"comment":"","sg":1.063,"time":` + FakeReading2DateString + `,"type":"manual"}]`,                                         // nolint:lll
+			Template:                  "{{.StatusIcon}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}d {{end}}{{.Recipe.Name}} {{.MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}}: {{.Reading.Gravity}} {{.Reading.Temperature}}\ue33e {{.TemperatureTrendIcon}}{{end}}", //nolint:lll
+			ExpectedString:            " 19d Fake Beer 1.3%: 1.063 21 ↗",
+			ExpectedEnabled:           true,
+		},
+		{
+			Case: "Fermenting Status, three readings, temp trending hard down, include age of most recent reading",
+			BatchJSONResponse: `
+			{"batchNo":18,"status":"Fermenting","brewDate":` + FakeBrewDateString + `,"bottlingDate":` + FakeBottlingDateString + `,"recipe":{"name":"Fake Beer"},"fermentationStartDate":` + FakeFermStartDateString + `,"name":"Batch","measuredAbv": 1.3}`, // nolint:lll
+			BatchReadingsJSONResponse: `[{"id":"manual","temp":19.5,"comment":"","sg":1.066,"time":` + FakeReading1DateString + `,"type":"manual"}, {"id":"manual","temp":21,"comment":"","sg":1.063,"time":` + FakeReading2DateString + `,"type":"manual"}, {"id":"manual","temp":15,"comment":"","sg":1.050,"time":` + FakeReading3DateString + `,"type":"manual"}]`, // nolint:lll
+			Template:                  "{{.StatusIcon}} {{.ReadingAge}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}d {{end}}{{.Recipe.Name}} {{.MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}}: {{.Reading.Gravity}} {{.Reading.Temperature}}\ue33e {{.TemperatureTrendIcon}}{{end}}",                                                  //nolint:lll
+			ExpectedString:            " 451 19d Fake Beer 1.3%: 1.05 15 ↓↓",
+			ExpectedEnabled:           true,
+		},
+		{
+			Case:                      "Bad batch json, readings fine",
+			BatchJSONResponse:         ``,
+			BatchReadingsJSONResponse: `[{"id":"manual","temp":19.5,"comment":"","sg":1.066,"time":` + FakeReading1DateString + `,"type":"manual"}, {"id":"manual","temp":21,"comment":"","sg":1.063,"time":` + FakeReading2DateString + `,"type":"manual"}, {"id":"manual","temp":15,"comment":"","sg":1.050,"time":` + FakeReading3DateString + `,"type":"manual"}]`, // nolint:lll
+			Template:                  "{{.StatusIcon}} {{.ReadingAge}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}d {{end}}{{.Recipe.Name}} {{.MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}}: {{.Reading.Gravity}} {{.Reading.Temperature}}\ue33e {{.TemperatureTrendIcon}}{{end}}",                                                  //nolint:lll
+			ExpectedString:            "",
+			ExpectedEnabled:           false,
+		},
+		{
+			Case: "Conditioning Status",
+			BatchJSONResponse: `
+			{"batchNo":18,"status":"Conditioning","brewDate":` + FakeBrewDateString + `,"bottlingDate":` + FakeBottlingDateString + `,"recipe":{"name":"Fake Beer"},"fermentationStartDate":` + FakeFermStartDateString + `,"name":"Batch","measuredAbv": 1.3}`, // nolint:lll
+			BatchReadingsJSONResponse: `[{"id":"manual","temp":19.5,"comment":"","sg":1.066,"time":` + FakeReading1DateString + `,"type":"manual"}, {"id":"manual","temp":21,"comment":"","sg":1.063,"time":` + FakeReading2DateString + `,"type":"manual"}, {"id":"manual","temp":15,"comment":"","sg":1.050,"time":` + FakeReading3DateString + `,"type":"manual"}]`, // nolint:lll
+			Template:                  "{{.StatusIcon}} {{if .DaysBottledOrFermented}}{{.DaysBottledOrFermented}}d {{end}}{{.Recipe.Name}} {{.MeasuredAbv}}%{{ if and (.Reading) (eq .Status \"Fermenting\")}}: {{.Reading.Gravity}} {{.Reading.Temperature}}\ue33e {{.TemperatureTrendIcon}}{{end}}",                                                                  //nolint:lll
+			ExpectedString:            " 5d Fake Beer 1.3%",
+			ExpectedEnabled:           true,
+		},
+	}
+
+	for _, tc := range cases {
+		env := &MockedEnvironment{}
+		var props properties = map[Property]interface{}{
+			CacheTimeout: tc.CacheTimeout,
+			BFBatchID:    BFFakeBatchID,
+			BFAPIKey:     "FAKE",
+			BFUserID:     "FAKE",
+		}
+
+		cache := &MockedCache{}
+		cache.On("get", BFCacheKey).Return(nil, false) // cache testing later because cache is a little more complicated than just the single response.
+		// cache.On("set", BFCacheKey, tc.JSONResponse, tc.CacheTimeout).Return()
+
+		env.On("doGet", BFBatchURL).Return([]byte(tc.BatchJSONResponse), tc.Error)
+		env.On("doGet", BFBatchReadingsURL).Return([]byte(tc.BatchReadingsJSONResponse), tc.Error)
+		env.On("cache", nil).Return(cache)
+
+		if tc.Template != "" {
+			props[SegmentTemplate] = tc.Template
+		}
+
+		ns := &brewfather{
+			props: props,
+			env:   env,
+		}
+
+		enabled := ns.enabled()
+		assert.Equal(t, tc.ExpectedEnabled, enabled, tc.Case)
+		if !enabled {
+			continue
+		}
+
+		assert.Equal(t, tc.ExpectedString, ns.string(), tc.Case)
+	}
+}
diff --git a/src/segment_path_test.go b/src/segment_path_test.go
index 5cb8dd7c..92a50ccb 100644
--- a/src/segment_path_test.go
+++ b/src/segment_path_test.go
@@ -134,7 +134,7 @@ func (env *MockedEnvironment) getWindowsRegistryKeyValue(path string) (*windowsR
 	return args.Get(0).(*windowsRegistryValue), args.Error(1)
 }
 
-func (env *MockedEnvironment) doGet(url string, timeout int) ([]byte, error) {
+func (env *MockedEnvironment) doGet(url string, timeout int, requestModifiers ...HTTPRequestModifier) ([]byte, error) {
 	args := env.Called(url)
 	return args.Get(0).([]byte), args.Error(1)
 }