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/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) } 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" + } + } + } + } + } } ] }