Skip to content

Commit

Permalink
Support new scheduled charging (#305)
Browse files Browse the repository at this point in the history
Adds support for weekly charging schedules.

Co-authored-by: Seth Terashima <[email protected]>
  • Loading branch information
patrickdemers6 and Seth Terashima committed Aug 29, 2024
1 parent fa40a30 commit d184c7c
Show file tree
Hide file tree
Showing 11 changed files with 2,046 additions and 676 deletions.
275 changes: 274 additions & 1 deletion cmd/tesla-control/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,28 @@ import (
"google.golang.org/protobuf/encoding/protojson"
)

var ErrCommandLineArgs = errors.New("invalid command line arguments")
var (
ErrCommandLineArgs = errors.New("invalid command line arguments")
ErrInvalidTime = errors.New("invalid time")
dayNamesBitMask = map[string]int32{
"SUN": 1,
"SUNDAY": 1,
"MON": 2,
"MONDAY": 2,
"TUES": 4,
"TUESDAY": 4,
"WED": 8,
"WEDNESDAY": 8,
"THURS": 16,
"THURSDAY": 16,
"FRI": 32,
"FRIDAY": 32,
"SAT": 64,
"SATURDAY": 64,
"ALL": 127,
"WEEKDAYS": 62,
}
)

type Argument struct {
name string
Expand All @@ -38,6 +59,49 @@ type Command struct {
domain protocol.Domain
}

func GetDegree(degStr string) (float32, error) {
deg, err := strconv.ParseFloat(degStr, 32)
if err != nil {
return 0.0, err
}
if deg < -180 || deg > 180 {
return 0.0, errors.New("latitude and longitude must both be in the range [-180, 180]")
}
return float32(deg), nil
}

func GetDays(days string) (int32, error) {
var mask int32
for _, d := range strings.Split(days, ",") {
if v, ok := dayNamesBitMask[strings.TrimSpace(strings.ToUpper(d))]; ok {
mask |= v
} else {
return 0, fmt.Errorf("unrecognized day name: %v", d)
}
}
return mask, nil
}

func MinutesAfterMidnight(hoursAndMinutes string) (int32, error) {
components := strings.Split(hoursAndMinutes, ":")
if len(components) != 2 {
return 0, fmt.Errorf("%w: expected HH:MM", ErrInvalidTime)
}
hours, err := strconv.Atoi(components[0])
if err != nil {
return 0, fmt.Errorf("%w: %s", ErrInvalidTime, err)
}
minutes, err := strconv.Atoi(components[1])
if err != nil {
return 0, fmt.Errorf("%w: %s", ErrInvalidTime, err)
}

if hours > 23 || hours < 0 || minutes > 59 || minutes < 0 {
return 0, fmt.Errorf("%w: hours or minutes outside valid range", ErrInvalidTime)
}
return int32(60*hours + minutes), nil
}

// configureAndVerifyFlags verifies that c contains all the information required to execute a command.
func configureFlags(c *cli.Config, commandName string, forceBLE bool) error {
info, ok := commands[commandName]
Expand Down Expand Up @@ -827,4 +891,213 @@ var commands = map[string]*Command{
return car.EraseGuestData(ctx)
},
},
"charging-schedule-add": &Command{
help: "Schedule charge for DAYS START_TIME-END_TIME at LATITUDE LONGITUDE. The END_TIME may be on the following day.",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "DAYS", help: "Comma-separated list of any of Sun, Mon, Tues, Wed, Thurs, Fri, Sat OR all OR weekdays"},
Argument{name: "TIME", help: "Time interval to charge (24-hour clock). Examples: '22:00-6:00', '-6:00', '20:32-"},
Argument{name: "LATITUDE", help: "Latitude of charging site"},
Argument{name: "LONGITUDE", help: "Longitude of charging site"},
},
optional: []Argument{
Argument{name: "REPEAT", help: "Set to 'once' or omit to repeat weekly"},
Argument{name: "ID", help: "The ID of the charge schedule to modify. Not required for new schedules."},
Argument{name: "ENABLED", help: "Whether the charge schedule is enabled. Expects 'true' or 'false'. Defaults to true."},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
var err error
schedule := vehicle.ChargeSchedule{
Id: uint64(time.Now().Unix()),
Enabled: true,
}

if enabledStr, ok := args["ENABLED"]; ok {
schedule.Enabled = enabledStr == "true"
}

schedule.DaysOfWeek, err = GetDays(args["DAYS"])
if err != nil {
return err
}

r := strings.Split(args["TIME"], "-")
if len(r) != 2 {
return errors.New("invalid time range")
}

if r[0] != "" {
schedule.StartTime, err = MinutesAfterMidnight(r[0])
schedule.StartEnabled = true
if err != nil {
return err
}
}

if r[1] != "" {
schedule.EndTime, err = MinutesAfterMidnight(r[1])
schedule.EndEnabled = true
if err != nil {
return err
}
}

schedule.Latitude, err = GetDegree(args["LATITUDE"])
if err != nil {
return err
}

schedule.Longitude, err = GetDegree(args["LONGITUDE"])
if err != nil {
return err
}

if repeatPolicy, ok := args["REPEAT"]; ok && repeatPolicy == "once" {
schedule.OneTime = true
}

if err := car.AddChargeSchedule(ctx, &schedule); err != nil {
return err
}
fmt.Printf("%d\n", schedule.Id)
return nil
},
},
"charging-schedule-remove": {
help: "Removes charging schedule of TYPE [ID]",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "TYPE", help: "home|work|other|id"},
},
optional: []Argument{
Argument{name: "ID", help: "numeric ID of schedule to remove when TYPE set to id"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
var home, work, other bool
switch strings.ToUpper(args["TYPE"]) {
case "ID":
if idStr, ok := args["ID"]; ok {
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New("expected numeric ID")
}
return car.RemoveChargeSchedule(ctx, id)
} else {
return errors.New("missing schedule ID")
}
case "HOME":
home = true
case "WORK":
work = true
case "OTHER":
other = true
default:
return errors.New("TYPE must be home|work|other|id")
}
return car.BatchRemoveChargeSchedules(ctx, home, work, other)
},
},
"precondition-schedule-add": &Command{
help: "Schedule precondition for DAYS TIME at LATITUDE LONGITUDE.",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "DAYS", help: "Comma-separated list of any of Sun, Mon, Tues, Wed, Thurs, Fri, Sat OR all OR weekdays"},
Argument{name: "TIME", help: "Time to precondition by. Example: '22:00'"},
Argument{name: "LATITUDE", help: "Latitude of location to precondition at."},
Argument{name: "LONGITUDE", help: "Longitude of location to precondition at."},
},
optional: []Argument{
Argument{name: "REPEAT", help: "Set to 'once' or omit to repeat weekly"},
Argument{name: "ID", help: "The ID of the precondition schedule to modify. Not required for new schedules."},
Argument{name: "ENABLED", help: "Whether the precondition schedule is enabled. Expects 'true' or 'false'. Defaults to true."},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
var err error
schedule := vehicle.PreconditionSchedule{
Id: uint64(time.Now().Unix()),
Enabled: true,
}

if enabledStr, ok := args["ENABLED"]; ok {
schedule.Enabled = enabledStr == "true"
}

if idStr, ok := args["ID"]; ok {
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New("expected numeric ID")
}
schedule.Id = id
}

schedule.DaysOfWeek, err = GetDays(args["DAYS"])
if err != nil {
return err
}

if timeStr, ok := args["TIME"]; ok {
schedule.PreconditionTime, err = MinutesAfterMidnight(timeStr)
} else {
return errors.New("expected TIME")
}

schedule.Latitude, err = GetDegree(args["LATITUDE"])
if err != nil {
return err
}

schedule.Longitude, err = GetDegree(args["LONGITUDE"])
if err != nil {
return err
}

if repeatPolicy, ok := args["REPEAT"]; ok && repeatPolicy == "once" {
schedule.OneTime = true
}

if err := car.AddPreconditionSchedule(ctx, &schedule); err != nil {
return err
}
fmt.Printf("%d\n", schedule.Id)
return nil
},
},
"precondition-schedule-remove": {
help: "Removes precondition schedule of TYPE [ID]",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "TYPE", help: "home|work|other|id"},
},
optional: []Argument{
Argument{name: "ID", help: "numeric ID of schedule to remove when TYPE set to id"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
var home, work, other bool
switch strings.ToUpper(args["TYPE"]) {
case "ID":
if idStr, ok := args["ID"]; ok {
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New("expected numeric ID")
}
return car.RemoveChargeSchedule(ctx, id)
} else {
return errors.New("missing schedule ID")
}
case "HOME":
home = true
case "WORK":
work = true
case "OTHER":
other = true
default:
return errors.New("TYPE must be home|work|other|id")
}
return car.BatchRemovePreconditionSchedules(ctx, home, work, other)
},
},
}
64 changes: 64 additions & 0 deletions cmd/tesla-control/commands_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"errors"
"strconv"
"testing"
)

func TestMinutesAfterMidnight(t *testing.T) {
type params struct {
str string
minutes int32
err error
}
testCases := []params{
{str: "3:03", minutes: 183},
{str: "0:00", minutes: 0},
{str: "", err: ErrInvalidTime},
{str: "3:", err: ErrInvalidTime},
{str: ":40", err: ErrInvalidTime},
{str: "3:40pm", err: ErrInvalidTime},
{str: "25:40", err: ErrInvalidTime},
{str: "23:40", minutes: 23*60 + 40},
{str: "23:60", err: ErrInvalidTime},
{str: "23:-01", err: ErrInvalidTime},
{str: "24:00", err: ErrInvalidTime},
{str: "-2:00", err: ErrInvalidTime},
}
for _, test := range testCases {
minutes, err := MinutesAfterMidnight(test.str)
if !errors.Is(err, test.err) {
t.Errorf("expected '%s' to result in error %s, but got %s", test.str, test.err, err)
} else if test.minutes != minutes {
t.Errorf("expected MinutesAfterMidnight('%s') = %d, but got %d", test.str, test.minutes, minutes)
}
}
}

func TestGetDays(t *testing.T) {
type params struct {
str string
mask int32
isErr bool
}
testCases := []params{
{str: "SUN", mask: 1},
{str: "SUN, WED", mask: 1 + 8},
{str: "SUN, WEDnesday", mask: 1 + 8},
{str: "sUN,wEd", mask: 1 + 8},
{str: "all", mask: 127},
{str: "sun,all", mask: 127},
{str: "mon,tues,wed,thurs", mask: 2 + 4 + 8 + 16},
{str: "marketday", isErr: true},
{str: "sun mon", isErr: true},
}
for _, test := range testCases {
mask, err := GetDays(test.str)
if (err != nil) != test.isErr {
t.Errorf("day string '%s' gave unexpected err = %s", test.str, err)
} else if mask != test.mask {
t.Errorf("day string '%s' gave mask %s instead of %s", test.str, strconv.FormatInt(int64(mask), 2), strconv.FormatInt(int64(test.mask), 2))
}
}
}
26 changes: 26 additions & 0 deletions pkg/protocol/protobuf/car_server.proto
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ message VehicleAction {
EraseUserDataAction eraseUserDataAction = 72;
VehicleControlSetPinToDriveAction vehicleControlSetPinToDriveAction = 77;
VehicleControlResetPinToDriveAction vehicleControlResetPinToDriveAction = 78;
ChargeSchedule addChargeScheduleAction = 97;
RemoveChargeScheduleAction removeChargeScheduleAction = 98;
PreconditionSchedule addPreconditionScheduleAction = 99;
RemovePreconditionScheduleAction removePreconditionScheduleAction = 100;
BatchRemovePreconditionSchedulesAction batchRemovePreconditionSchedulesAction = 107;
BatchRemoveChargeSchedulesAction batchRemoveChargeSchedulesAction = 108;
}
}

Expand Down Expand Up @@ -389,6 +395,26 @@ message SetChargingAmpsAction {
int32 charging_amps = 1;
}

message RemoveChargeScheduleAction {
uint64 id = 1; // datetime in epoch time
}

message BatchRemoveChargeSchedulesAction {
bool home = 1;
bool work = 2;
bool other = 3; // Delete non-home and non-work charge schedules
}

message BatchRemovePreconditionSchedulesAction {
bool home = 1;
bool work = 2;
bool other = 3; // Delete non-home and non-work precondition schedules
}

message RemovePreconditionScheduleAction {
uint64 id = 1; // datetime in epoch time
}

message SetCabinOverheatProtectionAction {
bool on = 1;
bool fan_only = 2;
Expand Down
Loading

0 comments on commit d184c7c

Please sign in to comment.