Skip to content

Commit 9095819

Browse files
committed
feat: expand charging and automation insights
Add next-charge recommendations comparing home TOU costs with Supercharger quotes, surfaced on dashboards and vehicle details. Expand automation, routine, and alert template catalogs with category filtering, localization, and tests.
1 parent 8969c19 commit 9095819

34 files changed

Lines changed: 2724 additions & 127 deletions

internal/api/automation/routines.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,139 @@ func RoutineTemplates() []RoutineTemplate {
6060
Event: "exit",
6161
Actions: []RoutineAction{{Command: "climate_on"}},
6262
},
63+
{
64+
ID: "arrive_work",
65+
Name: "Arrive at Work",
66+
Description: "When you arrive at work: turn Sentry off and climate off.",
67+
Event: "enter",
68+
Actions: []RoutineAction{{Command: "sentry_off"}, {Command: "climate_off"}},
69+
},
70+
{
71+
ID: "leave_home_climate",
72+
Name: "Leave Home — Pre-condition",
73+
Description: "When you leave home: start climate and lock the doors.",
74+
Event: "exit",
75+
Actions: []RoutineAction{{Command: "climate_on"}, {Command: "lock"}},
76+
},
77+
{
78+
ID: "arrive_home_climate_off",
79+
Name: "Arrive Home — Climate Off",
80+
Description: "When you arrive home: stop HVAC and lock.",
81+
Event: "enter",
82+
Actions: []RoutineAction{{Command: "climate_off"}, {Command: "lock"}},
83+
},
84+
{
85+
ID: "arrive_home_windows",
86+
Name: "Arrive Home — Close Windows",
87+
Description: "When you arrive home: close windows and lock.",
88+
Event: "enter",
89+
Actions: []RoutineAction{{Command: "close_windows"}, {Command: "lock"}},
90+
},
91+
{
92+
ID: "leave_home_windows",
93+
Name: "Leave Home — Close Windows",
94+
Description: "When you leave home: close windows, arm Sentry, lock.",
95+
Event: "exit",
96+
Actions: []RoutineAction{{Command: "close_windows"}, {Command: "sentry_on"}, {Command: "lock"}},
97+
},
98+
{
99+
ID: "arrive_charger_port",
100+
Name: "Arrive at Charger — Open Port",
101+
Description: "When you arrive at a charger: open the charge port and cap at 80%.",
102+
Event: "enter",
103+
Actions: []RoutineAction{{Command: "open_charge_port"}, {Command: "set_charge_limit", Params: map[string]any{"percent": 80}}},
104+
},
105+
{
106+
ID: "leave_charger",
107+
Name: "Leave Charger",
108+
Description: "When you leave a charger: stop charging, close the port, lock.",
109+
Event: "exit",
110+
Actions: []RoutineAction{{Command: "charge_stop"}, {Command: "close_charge_port"}, {Command: "lock"}},
111+
},
112+
{
113+
ID: "arrive_home_homelink",
114+
Name: "Arrive Home — HomeLink",
115+
Description: "When you arrive home: trigger HomeLink and disarm Sentry.",
116+
Event: "enter",
117+
Actions: []RoutineAction{{Command: "trigger_homelink"}, {Command: "sentry_off"}},
118+
},
119+
{
120+
ID: "leave_home_homelink",
121+
Name: "Leave Home — HomeLink",
122+
Description: "When you leave home: trigger HomeLink and arm Sentry.",
123+
Event: "exit",
124+
Actions: []RoutineAction{{Command: "trigger_homelink"}, {Command: "sentry_on"}},
125+
},
126+
{
127+
ID: "arrive_work_lock",
128+
Name: "Arrive at Work — Lock",
129+
Description: "When you arrive at work: lock, close windows, arm Sentry.",
130+
Event: "enter",
131+
Actions: []RoutineAction{{Command: "lock"}, {Command: "close_windows"}, {Command: "sentry_on"}},
132+
},
133+
{
134+
ID: "leave_work_seats",
135+
Name: "Leave Work — Climate + Seat Heat",
136+
Description: "When you leave work: start climate and heat the driver seat.",
137+
Event: "exit",
138+
Actions: []RoutineAction{{Command: "climate_on"}, {Command: "seat_heater", Params: map[string]any{"seat": 0, "level": 2}}},
139+
},
140+
{
141+
ID: "arrive_supercharger",
142+
Name: "Arrive at Supercharger",
143+
Description: "When you arrive at a Supercharger: open the port and set limit 80%.",
144+
Event: "enter",
145+
Actions: []RoutineAction{{Command: "open_charge_port"}, {Command: "set_charge_limit", Params: map[string]any{"percent": 80}}},
146+
},
147+
{
148+
ID: "leave_supercharger",
149+
Name: "Leave Supercharger",
150+
Description: "When you leave a Supercharger: close the port and lock.",
151+
Event: "exit",
152+
Actions: []RoutineAction{{Command: "close_charge_port"}, {Command: "lock"}},
153+
},
154+
{
155+
ID: "arrive_home_wake",
156+
Name: "Arrive Home — Flash Lights",
157+
Description: "When you arrive home: flash lights so you can find the stall.",
158+
Event: "enter",
159+
Actions: []RoutineAction{{Command: "flash_lights"}},
160+
},
161+
{
162+
ID: "leave_home_wake_climate",
163+
Name: "Leave Home — Wake + Climate",
164+
Description: "When you leave home: start climate and unlock.",
165+
Event: "exit",
166+
Actions: []RoutineAction{{Command: "climate_on"}, {Command: "unlock"}},
167+
},
168+
{
169+
ID: "arrive_school",
170+
Name: "Arrive at School",
171+
Description: "When you arrive at school: lock and arm Sentry.",
172+
Event: "enter",
173+
Actions: []RoutineAction{{Command: "lock"}, {Command: "sentry_on"}},
174+
},
175+
{
176+
ID: "leave_school",
177+
Name: "Leave School",
178+
Description: "When you leave school: start climate for the drive home.",
179+
Event: "exit",
180+
Actions: []RoutineAction{{Command: "climate_on"}},
181+
},
182+
{
183+
ID: "arrive_airport",
184+
Name: "Arrive at Airport",
185+
Description: "When you arrive at the airport: lock, close windows, arm Sentry.",
186+
Event: "enter",
187+
Actions: []RoutineAction{{Command: "lock"}, {Command: "close_windows"}, {Command: "sentry_on"}},
188+
},
189+
{
190+
ID: "leave_airport",
191+
Name: "Leave Airport",
192+
Description: "When you leave the airport: disarm Sentry and start climate.",
193+
Event: "exit",
194+
Actions: []RoutineAction{{Command: "sentry_off"}, {Command: "climate_on"}},
195+
},
63196
}
64197
}
65198

internal/api/automation/routines_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import (
1212

1313
func TestRoutineTemplatesCatalogue(t *testing.T) {
1414
all := RoutineTemplates()
15-
if len(all) != 4 {
16-
t.Fatalf("templates = %d, want 4", len(all))
15+
if len(all) < 20 {
16+
t.Fatalf("templates = %d, want at least 20", len(all))
1717
}
1818
seen := map[string]bool{}
1919
for _, tmpl := range all {
@@ -45,8 +45,8 @@ func TestListRoutineTemplates(t *testing.T) {
4545
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
4646
t.Fatal(err)
4747
}
48-
if len(out) != 4 {
49-
t.Fatalf("templates = %d, want 4", len(out))
48+
if len(out) < 20 {
49+
t.Fatalf("templates = %d, want at least 20", len(out))
5050
}
5151
}
5252

internal/api/nextcharge/decide.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package nextcharge
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"time"
7+
8+
"github.com/ev-dev-labs/teslasync/internal/api/chargeautopilot"
9+
)
10+
11+
// Verdicts returned by Decide. The frontend maps these to i18n copy.
12+
const (
13+
VerdictEnough = "enough"
14+
VerdictWait = "wait"
15+
VerdictChargeHomeNow = "charge_home_now"
16+
VerdictSupercharger = "supercharger"
17+
VerdictSkipDC = "skip_dc"
18+
)
19+
20+
// Reason keys for i18n interpolation (nextCharge.reason.*).
21+
const (
22+
ReasonEnough = "enough"
23+
ReasonWaitOffpeak = "wait_offpeak"
24+
ReasonSuperchargerFaster = "supercharger_faster"
25+
ReasonSuperchargerCheaper = "supercharger_cheaper"
26+
ReasonSkipDC = "skip_dc"
27+
ReasonChargeHomeNow = "charge_home_now"
28+
)
29+
30+
const (
31+
defaultHorizon = 12 * time.Hour
32+
minWaitLead = 15 * time.Minute
33+
minWaitSavingsUSD = 0.50
34+
scCheaperRatio = 0.90 // Supercharger wins if < 90% of the cheaper home option
35+
scPremiumRatio = 1.10 // skip DC if Supercharger is ≥10% more than charge-now
36+
)
37+
38+
// Quote is the cheapest billed Supercharger/DC site for this VIN.
39+
type Quote struct {
40+
Site string
41+
AvgPerKWh float64 // USD per kWh from Tesla invoices
42+
}
43+
44+
// Input seeds a 12-hour energy verdict.
45+
type Input struct {
46+
Profile chargeautopilot.Profile
47+
CurrentSOC int
48+
Now time.Time
49+
Horizon time.Duration
50+
Quote *Quote
51+
}
52+
53+
// Decision is the GET /charge-autopilot/decision wire shape.
54+
type Decision struct {
55+
Verdict string `json:"verdict"`
56+
ReasonKey string `json:"reason_key"`
57+
Reason string `json:"reason"`
58+
CurrentSOC int `json:"current_soc"`
59+
TargetSOC int `json:"target_soc"`
60+
KWhNeeded float64 `json:"kwh_needed"`
61+
HorizonHours float64 `json:"horizon_hours"`
62+
HomeNowCost *float64 `json:"home_now_cost,omitempty"`
63+
HomeWaitCost *float64 `json:"home_wait_cost,omitempty"`
64+
HomeWaitStart *time.Time `json:"home_wait_start,omitempty"`
65+
HomeSavings *float64 `json:"home_savings,omitempty"`
66+
SuperchargerSite *string `json:"supercharger_site,omitempty"`
67+
SuperchargerPerKWh *float64 `json:"supercharger_per_kwh,omitempty"`
68+
SuperchargerCost *float64 `json:"supercharger_cost,omitempty"`
69+
ReadyBy time.Time `json:"ready_by"`
70+
CappedByHealth bool `json:"capped_by_health_guardrail"`
71+
}
72+
73+
// Decide returns the next-charge verdict. Pure: no I/O.
74+
func Decide(in Input) Decision {
75+
now := in.Now
76+
horizon := in.Horizon
77+
if horizon <= 0 {
78+
horizon = defaultHorizon
79+
}
80+
p := in.Profile
81+
target, capped := chargeautopilot.EffectiveTarget(p.TargetSOC, p.DailyCapSOC, p.TripOverride)
82+
readyBy, err := chargeautopilot.NextReadyBy(p.ReadyBy, now)
83+
if err != nil {
84+
readyBy = now.Add(24 * time.Hour)
85+
}
86+
87+
d := Decision{
88+
CurrentSOC: in.CurrentSOC,
89+
TargetSOC: target,
90+
HorizonHours: horizon.Hours(),
91+
ReadyBy: readyBy,
92+
CappedByHealth: capped,
93+
}
94+
attachQuote(&d, in.Quote, 0)
95+
96+
if in.CurrentSOC >= target {
97+
d.KWhNeeded = 0
98+
d.Verdict = VerdictEnough
99+
d.ReasonKey = ReasonEnough
100+
d.Reason = fmt.Sprintf("Battery is at %d%%, already at the %d%% target.", in.CurrentSOC, target)
101+
return d
102+
}
103+
104+
kwhNeeded := round2(float64(target-in.CurrentSOC) / 100.0 * p.BatteryCapacityKWh)
105+
d.KWhNeeded = kwhNeeded
106+
attachQuote(&d, in.Quote, kwhNeeded)
107+
108+
preview, previewErr := chargeautopilot.Preview(chargeautopilot.PreviewInput{
109+
Profile: p,
110+
CurrentSOC: in.CurrentSOC,
111+
Now: now,
112+
})
113+
if previewErr != nil || preview == nil {
114+
if in.Quote != nil && in.Quote.AvgPerKWh > 0 {
115+
d.Verdict = VerdictSupercharger
116+
d.ReasonKey = ReasonSuperchargerFaster
117+
d.Reason = fmt.Sprintf(
118+
"Home charging cannot finish before ready-by; %s is the cheapest billed Supercharger at $%.2f/kWh.",
119+
in.Quote.Site, in.Quote.AvgPerKWh,
120+
)
121+
return d
122+
}
123+
d.Verdict = VerdictChargeHomeNow
124+
d.ReasonKey = ReasonChargeHomeNow
125+
d.Reason = "Start charging at home now — no cheaper Supercharger quote and no feasible off-peak window."
126+
return d
127+
}
128+
129+
d.HomeNowCost = ptrf(round2(preview.ChargeNowCost))
130+
d.HomeWaitCost = ptrf(round2(preview.OptimizedCost))
131+
d.HomeSavings = ptrf(round2(preview.Savings))
132+
waitStart := preview.Window.StartTime
133+
d.HomeWaitStart = &waitStart
134+
d.KWhNeeded = round2(preview.KWhNeeded)
135+
attachQuote(&d, in.Quote, preview.KWhNeeded)
136+
137+
homeFloor := preview.OptimizedCost
138+
if preview.ChargeNowCost < homeFloor {
139+
homeFloor = preview.ChargeNowCost
140+
}
141+
scCost := 0.0
142+
if in.Quote != nil && in.Quote.AvgPerKWh > 0 {
143+
scCost = round2(in.Quote.AvgPerKWh * preview.KWhNeeded)
144+
}
145+
146+
if in.Quote != nil && scCost > 0 && homeFloor > 0 && scCost < homeFloor*scCheaperRatio {
147+
d.Verdict = VerdictSupercharger
148+
d.ReasonKey = ReasonSuperchargerCheaper
149+
d.Reason = fmt.Sprintf(
150+
"%s is cheaper ($%.2f vs $%.2f at home) for the %.1f kWh you still need.",
151+
in.Quote.Site, scCost, homeFloor, preview.KWhNeeded,
152+
)
153+
return d
154+
}
155+
156+
waitOK := waitStart.After(now.Add(minWaitLead)) &&
157+
!waitStart.After(now.Add(horizon)) &&
158+
preview.Savings >= minWaitSavingsUSD
159+
if waitOK {
160+
d.Verdict = VerdictWait
161+
d.ReasonKey = ReasonWaitOffpeak
162+
d.Reason = fmt.Sprintf(
163+
"Wait for off-peak at %s — save $%.2f versus charging now.",
164+
waitStart.Format(time.Kitchen), preview.Savings,
165+
)
166+
return d
167+
}
168+
169+
if in.Quote != nil && scCost > 0 && preview.ChargeNowCost > 0 && scCost > preview.ChargeNowCost*scPremiumRatio {
170+
d.Verdict = VerdictSkipDC
171+
d.ReasonKey = ReasonSkipDC
172+
d.Reason = fmt.Sprintf(
173+
"Skip %s ($%.2f) — home now is $%.2f for the same energy.",
174+
in.Quote.Site, scCost, preview.ChargeNowCost,
175+
)
176+
return d
177+
}
178+
179+
d.Verdict = VerdictChargeHomeNow
180+
d.ReasonKey = ReasonChargeHomeNow
181+
d.Reason = "Charge at home now — the cheapest window is already open (or too far out to wait)."
182+
return d
183+
}
184+
185+
func attachQuote(d *Decision, q *Quote, kwh float64) {
186+
if q == nil || q.AvgPerKWh <= 0 {
187+
return
188+
}
189+
d.SuperchargerSite = ptrs(q.Site)
190+
d.SuperchargerPerKWh = ptrf(round4(q.AvgPerKWh))
191+
if kwh > 0 {
192+
d.SuperchargerCost = ptrf(round2(q.AvgPerKWh * kwh))
193+
}
194+
}
195+
196+
func ptrf(v float64) *float64 { return &v }
197+
func ptrs(v string) *string { return &v }
198+
199+
func round2(f float64) float64 { return math.Round(f*100) / 100 }
200+
func round4(f float64) float64 { return math.Round(f*10000) / 10000 }

0 commit comments

Comments
 (0)