diff --git a/client.go b/client.go index 979b496..28fcff7 100644 --- a/client.go +++ b/client.go @@ -5,6 +5,7 @@ import ( "errors" "io" "log/slog" + "regexp" "slices" "sync" "time" @@ -17,14 +18,15 @@ import ( type Client struct { credentials config.Credentials httpClient *resty.Client - country string // USA | CA - updateInterval int // 7200 - fetchInterval int // 360 + country string // USA | CA + contactMethods dataMap // List of contact methods for 2FA currentVin string listOfVins []string isAuthenticated bool isRegistered bool isAlive bool + updateInterval int // 7200 + fetchInterval int // 360 logger *slog.Logger sync.RWMutex } @@ -49,45 +51,73 @@ func New(config *config.Config) (*Client, error) { "X-Requested-With": MOBILE_APP[client.country], "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate", - "Accept": "*/*"}) + "Accept": "*/*"}, + ) client.httpClient = httpClient - resp, err := client.auth() - if err != nil { + + if ok, err := client.auth(); !ok { client.logger.Error("error while executing auth request", "request", "auth", "error", err.Error()) return nil, errors.New("error while executing auth request: " + err.Error()) } + return client, nil +} + +// auth authenticates the client with the MySubaru API using the provided credentials. +func (c *Client) auth() (bool, error) { + params := map[string]string{ + "env": "cloudprod", + "deviceType": "android", + "loginUsername": c.credentials.Username, + "password": c.credentials.Password, + "deviceId": c.credentials.DeviceID, + "passwordToken": "", + "selectedVin": "", + "pushToken": ""} + reqURL := MOBILE_API_VERSION + apiURLs["API_LOGIN"] + resp, err := c.execute(POST, reqURL, params, false) + if err != nil { + c.logger.Error("error while executing auth request", "request", "auth", "error", err.Error()) + return false, errors.New("error while executing auth request: " + err.Error()) + } + c.logger.Debug("http request output", "request", "auth", "body", resp) + var sd SessionData err = json.Unmarshal(resp.Data, &sd) if err != nil { - client.logger.Error("error while parsing json", "request", "auth", "error", err.Error()) + c.logger.Error("error while parsing json", "request", "auth", "error", err.Error()) } // client.logger.Debug("unmarshaled json data", "request", "auth", "type", "sessionData", "body", sd) - if sd.DeviceRegistered && sd.RegisteredDevicePermanent { - // client.logger.Debug("client authentication successful") - client.isAuthenticated = true - client.isRegistered = true - } - // TODO: Work on registerDevice() - // } else { - // // client.logger.Debug("client authentication successful, but devices is not registered") - // client.registerDevice() - // } + if !sd.DeviceRegistered { + err := c.getContactMethods() + if err != nil { + c.logger.Error("error while getting contact methods", "request", "auth", "error", err.Error()) + return false, errors.New("error while getting contact methods: " + err.Error()) + } + + c.logger.Error("device is not registered", "request", "auth", "deviceId", c.credentials.DeviceID) + return false, errors.New("device is not registered: " + c.credentials.DeviceID) + } + + if sd.DeviceRegistered && sd.RegisteredDevicePermanent { + c.isAuthenticated = true + c.isRegistered = true + c.isAlive = true + } + c.logger.Debug("MySubaru API client authenticated") - // client.logger.Debug("parsing cars assigned to the account", "quantity", len(sd.Vehicles)) if len(sd.Vehicles) > 0 { for _, vehicle := range sd.Vehicles { - // client.logger.Debug("parsing car", "vin", vehicle.Vin) - client.listOfVins = append(client.listOfVins, vehicle.Vin) + c.listOfVins = append(c.listOfVins, vehicle.Vin) } - client.currentVin = client.listOfVins[0] + c.currentVin = c.listOfVins[0] } else { - client.logger.Error("there are no cars assigned to the account") - return nil, err + c.logger.Error("there are no vehicles associated with the account", "request", "auth", "error", "no vehicles found") + return false, err } - return client, nil + return true, nil } // SelectVehicle selects a vehicle by its VIN. If no VIN is provided, it uses the current VIN. @@ -142,17 +172,17 @@ func (c *Client) GetVehicleByVin(vin string) (*Vehicle, error) { reqURL := MOBILE_API_VERSION + apiURLs["API_SELECT_VEHICLE"] resp, err := c.execute(GET, reqURL, params, false) if err != nil { - c.logger.Error("error while executing GetVehicleByVIN request", "request", "GetVehicleByVIN", "error", err.Error()) - return nil, errors.New("error while executing GetVehicleByVIN request: " + err.Error()) + c.logger.Error("error while executing GetVehicleByVin request", "request", "GetVehicleByVin", "error", err.Error()) + return nil, errors.New("error while executing GetVehicleByVin request: " + err.Error()) } - // c.logger.Debug("http request output", "request", "GetVehicleByVIN", "body", resp) + // c.logger.Debug("http request output", "request", "GetVehicleByVin", "body", resp) var vd VehicleData err = json.Unmarshal(resp.Data, &vd) if err != nil { - c.logger.Error("error while parsing json", "request", "GetVehicleByVIN", "error", err.Error()) + c.logger.Error("error while parsing json", "request", "GetVehicleByVin", "error", err.Error()) } - // c.logger.Debug("http request output", "request", "GetVehicleByVIN", "body", resp) + // c.logger.Debug("http request output", "request", "GetVehicleByVin", "body", resp) vehicle = &Vehicle{ Vin: vin, @@ -198,6 +228,7 @@ func (c *Client) GetVehicleByVin(vin string) (*Vehicle, error) { } // RefreshVehicles refreshes the list of vehicles associated with the client's account. +// {"success":true,"dataName":"sessionData","data":{"sessionChanged":true,"vehicleInactivated":false,"account":{"marketId":1,"createdDate":1476984644000,"firstName":"Tatiana","lastName":"Savin","zipCode":"07974","accountKey":765268,"lastLoginDate":1751835464000,"zipCode5":"07974"},"resetPassword":false,"deviceId":"JddMBQXvAkgutSmEP6uFsThbq4QgEBBQ","sessionId":"0E154A99FA3D014D866840123E5E666A","deviceRegistered":true,"passwordToken":null,"vehicles":[{"customer":{"sessionCustomer":{"firstName":"Tatiana","lastName":"Savin","title":"","suffix":"","email":"tanya@savin.nyc","address":"29a Marion Ave","address2":"","city":"New Providence","state":"NJ","zip":"07974-1906","cellularPhone":"","workPhone":"","homePhone":"4013050505","countryCode":"USA","relationshipType":null,"gender":"","dealerCode":null,"oemCustId":"CRM-41PLM-5TYE","createMysAccount":null,"sourceSystemCode":"mys","vehicles":[{"vin":"4S4BSETC4H3265676","siebelVehicleRelationship":"Previous Owner","primary":false,"oemCustId":"1-8K7OBOJ","status":""},{"vin":"4S4BSETC4H3265676","siebelVehicleRelationship":"Previous TM Subscriber","primary":false,"oemCustId":"1-8JY3UVS","status":"Inactive"},{"vin":"4S4BTGND8L3137058","siebelVehicleRelationship":"Previous TM Subscriber","primary":false,"oemCustId":"CRM-44UFUA14-V","status":"Draft"},{"vin":"4S4BTGPD0P3199198","siebelVehicleRelationship":"TM Subscriber","primary":true,"oemCustId":"CRM-41PLM-5TYE","status":"Active"}],"phone":"","zip5Digits":"07974","primaryPersonalCountry":"USA"},"email":"","firstName":"Tatiana","lastName":"Savin","zip":"07974-1906","oemCustId":"CRM-41PLM-5TYE","phone":""},"vehicleName":"Subaru Outback TXT","stolenVehicle":false,"vin":"4S4BTGPD0P3199198","modelYear":null,"modelCode":null,"engineSize":null,"nickname":"Subaru Outback TXT","vehicleKey":8211380,"active":true,"licensePlate":"","licensePlateState":"","email":"tanya@savin.nyc","firstName":"Tatiana","lastName":"Savin","subscriptionFeatures":["REMOTE","SAFETY","Retail3"],"accessLevel":-1,"zip":"07974-1906","oemCustId":"CRM-41PLM-5TYE","vehicleMileage":null,"phone":"","timeZone":"America/New_York","features":["ABS_MIL","ACCS","AHBL_MIL","ATF_MIL","AWD_MIL","BSD","BSDRCT_MIL","CEL_MIL","CP1_5HHU","EBD_MIL","EOL_MIL","EPAS_MIL","EPB_MIL","ESS_MIL","EYESIGHT","ISS_MIL","MOONSTAT","OPL_MIL","PANPM-TUIRWAOC","PWAAADWWAP","RAB_MIL","RCC","REARBRK","RES","RESCC","RES_HVAC_HFS","RES_HVAC_VFS","RHSF","RPOI","RPOIA","RTGU","RVFS","SRH_MIL","SRS_MIL","SXM360L","T23DCM","TEL_MIL","TIF_35","TIR_33","TLD","TPMS_MIL","VALET","VDC_MIL","WASH_MIL","WDWSTAT","g3"],"userOemCustId":"CRM-41PLM-5TYE","subscriptionStatus":"ACTIVE","authorizedVehicle":false,"preferredDealer":null,"cachedStateCode":"NJ","modelName":null,"subscriptionPlans":[],"crmRightToRepair":false,"needMileagePrompt":false,"phev":null,"extDescrip":null,"sunsetUpgraded":true,"intDescrip":null,"transCode":null,"provisioned":true,"remoteServicePinExist":true,"needEmergencyContactPrompt":false,"vehicleGeoPosition":null,"show3gSunsetBanner":false,"vehicleBranded":false}],"rightToRepairEnabled":true,"rightToRepairStartYear":2022,"rightToRepairStates":"MA","enableXtime":true,"termsAndConditionsAccepted":true,"digitalGlobeConnectId":"0572e32b-2fcf-4bc8-abe0-1e3da8767132","digitalGlobeImageTileService":"https://earthwatch.digitalglobe.com/earthservice/tmsaccess/tms/1.0.0/DigitalGlobe:ImageryTileService@EPSG:3857@png/{z}/{x}/{y}.png?connectId=0572e32b-2fcf-4bc8-abe0-1e3da8767132","digitalGlobeTransparentTileService":"https://earthwatch.digitalglobe.com/earthservice/tmsaccess/tms/1.0.0/Digitalglobe:OSMTransparentTMSTileService@EPSG:3857@png/{z}/{x}/{-y}.png/?connectId=0572e32b-2fcf-4bc8-abe0-1e3da8767132","tomtomKey":"DHH9SwEQ4MW55Hj2TfqMeldbsDjTdgAs","currentVehicleIndex":0,"handoffToken":"$2a$08$EvONydxMhrhVgFsv4OgX3O8QaOu3naCFYex2Crqhl27cPQwJYXera$1751837730624","satelliteViewEnabled":true,"registeredDevicePermanent":true}} func (c *Client) RefreshVehicles() error { params := map[string]string{} reqURL := MOBILE_API_VERSION + apiURLs["API_REFRESH_VEHICLES"] @@ -219,9 +250,24 @@ func (c *Client) RefreshVehicles() error { } // RequestAuthCode requests an authentication code for two-factor authentication (2FA). -func (c *Client) RequestAuthCode() error { +// (?!^).(?=.*@) +// (?!^): This is a negative lookbehind assertion. It ensures that the matched character is not at the beginning of the string. +// .: This matches any single character (except newline, by default). +// (?=.*@): This is a positive lookahead assertion. It ensures that the matched character is followed by any characters (.*) and then an "@" symbol. This targets the username part of the email address. +func (c *Client) RequestAuthCode(email string) error { + email, err := emailMasking(email) + if err != nil { + c.logger.Error("error while hiding email", "request", "RequestAuthCode", "error", err.Error()) + return errors.New("error while hiding email: " + err.Error()) + } + + if !containsValueInStruct(c.contactMethods, email) { + c.logger.Error("email is not in the list of contact methods", "request", "RequestAuthCode", "email", email) + return errors.New("email is not in the list of contact methods: " + email) + } + params := map[string]string{ - "contactMethod": "0", + "contactMethod": email, "languagePreference": "EN"} reqUrl := MOBILE_API_VERSION + apiURLs["API_2FA_SEND_VERIFICATION"] resp, err := c.execute(POST, reqUrl, params, false) @@ -236,6 +282,12 @@ func (c *Client) RequestAuthCode() error { // SubmitAuthCode submits the authentication code received from the RequestAuthCode method. func (c *Client) SubmitAuthCode(code string, permanent bool) error { + regex := regexp.MustCompile(`^\d{6}$`) + if !regex.MatchString(code) { + c.logger.Error("invalid verification code format", "request", "SubmitAuthCode", "code", code) + return errors.New("invalid verification code format, must be 6 digits") + } + params := map[string]string{ "deviceId": c.credentials.DeviceID, "deviceName": c.credentials.DeviceName, @@ -250,34 +302,44 @@ func (c *Client) SubmitAuthCode(code string, permanent bool) error { c.logger.Error("error while executing SubmitAuthCode request", "request", "SubmitAuthCode", "error", err.Error()) return errors.New("error while executing SubmitAuthCode request: " + err.Error()) } + c.logger.Debug("http request output", "request", "SubmitAuthCode", "body", resp) + // Device registration does not always immediately take effect time.Sleep(time.Second * 3) - c.logger.Debug("http request output", "request", "SubmitAuthCode", "body", resp) + + // Reauthenticate after submitting the code + if ok, err := c.auth(); !ok { + c.logger.Error("error while executing auth request", "request", "auth", "error", err.Error()) + return errors.New("error while executing auth request: " + err.Error()) + } return nil } // getContactMethods retrieves the available contact methods for two-factor authentication (2FA). -func (c *Client) GetContactMethods() error { +// {"success":true,"dataName":"dataMap","data":{"userName":"a**x@savin.nyc","email":"t***a@savin.nyc"}} +func (c *Client) getContactMethods() error { params := map[string]string{} reqUrl := MOBILE_API_VERSION + apiURLs["API_2FA_CONTACT"] resp, err := c.execute(POST, reqUrl, params, false) if err != nil { - c.logger.Error("error while executing GetContactMethods request", "request", "GetContactMethods", "error", err.Error()) - return errors.New("error while executing GetContactMethods request: " + err.Error()) + c.logger.Error("error while executing getContactMethods request", "request", "getContactMethods", "error", err.Error()) + return errors.New("error while executing getContactMethods request: " + err.Error()) } - c.logger.Debug("http request output", "request", "GetContactMethods", "body", resp) + c.logger.Debug("http request output", "request", "getContactMethods", "body", resp) + + var dm dataMap + err = json.Unmarshal(resp.Data, &dm) + if err != nil { + c.logger.Error("error while parsing json", "request", "getContactMethods", "error", err.Error()) + return errors.New("error while parsing json while getting contact methods: " + err.Error()) + } + c.contactMethods = dm + c.logger.Debug("contact methods successfully retrieved", "request", "getContactMethods", "methods", dm) return nil } -// func isPINRequired() {} -// func getEVStatus() {} -// func getRemoteOptionsStatus() {} -// func getRemoteStartStatus() {} -// func getSafetyStatus() {} -// func getSubscriptionStatus() {} - // IsAlive checks if the Client instance is alive func (c *Client) IsAlive() bool { return c.isAlive @@ -335,6 +397,8 @@ func (c *Client) execute(method string, url string, params map[string]string, j } c.logger.Debug("parsed http request output", "data", string(resBytes)) + c.httpClient.SetCookies(resp.Cookies()) + if r, ok := c.parseResponse(resBytes); ok { c.isAlive = true c.Unlock() @@ -361,28 +425,6 @@ func (c *Client) execute(method string, url string, params map[string]string, j return nil, errors.New("request is not successfull, HTTP code: " + resp.Status()) } -// auth authenticates the client with the MySubaru API using the provided credentials. -func (c *Client) auth() (*Response, error) { - params := map[string]string{ - "env": "cloudprod", - "deviceType": "android", - "loginUsername": c.credentials.Username, - "password": c.credentials.Password, - "deviceId": c.credentials.DeviceID, - "passwordToken": "", - "selectedVin": "", - "pushToken": ""} - reqURL := MOBILE_API_VERSION + apiURLs["API_LOGIN"] - resp, err := c.execute(POST, reqURL, params, false) - c.logger.Debug("AUTH HTTP OUTPUT", "body", resp) - if err != nil { - c.logger.Error("error while executing auth request", "request", "auth", "error", err.Error()) - return nil, errors.New("error while executing auth request: " + err.Error()) - } - c.logger.Debug("AUTH HTTP OUTPUT", "body", resp) - return resp, nil -} - // parseResponse parses the JSON response from the MySubaru API into a Response struct. func (c *Client) parseResponse(b []byte) (Response, bool) { var r Response @@ -407,6 +449,13 @@ func (c *Client) ValidateSession() bool { return true } +// func isPINRequired() {} +// func getEVStatus() {} +// func getRemoteOptionsStatus() {} +// func getRemoteStartStatus() {} +// func getSafetyStatus() {} +// func getSubscriptionStatus() {} + // validateSession . // TODO: add session validation process and add it to the proper functions // func (c *Client) validateSession() bool { diff --git a/consts.go b/consts.go index bf91311..e7f8871 100644 --- a/consts.go +++ b/consts.go @@ -95,23 +95,23 @@ var apiURLs = map[string]string{ // } var API_ERRORS = map[string]string{ - "403-soa-unableToParseResponseBody": "ERROR_SOA_403", // G2 Error Codes - "InvalidCredentials": "ERROR_INVALID_CREDENTIALS", - "ServiceAlreadyStarted": "ERROR_SERVICE_ALREADY_STARTED", - "invalidAccount": "ERROR_INVALID_ACCOUNT", - "passwordWarning": "ERROR_PASSWORD_WARNING", - "accountLocked": "ERROR_ACCOUNT_LOCKED", - "noVehiclesOnAccount": "ERROR_NO_VEHICLES", - "noVehiclesAvailable": "ERROR_NO_VEHICLE_AVAILABLE", - "VEHICLESETUPERROR": "ERROR_VEHICLE_SETUP_ERROR", // Vehicle Select - "accountNotFound": "ERROR_NO_ACCOUNT", - "tooManyAttempts": "ERROR_TOO_MANY_ATTEMPTS", - "vehicleNotInAccount": "ERROR_VEHICLE_NOT_IN_ACCOUNT", - "SXM40004": "ERROR_G1_NO_SUBSCRIPTION", // G1 Error Codes - "SXM40005": "ERROR_G1_STOLEN_VEHICLE", - "SXM40006": "ERROR_G1_INVALID_PIN", - "SXM40009": "ERROR_G1_SERVICE_ALREADY_STARTED", - "SXM40017": "ERROR_G1_PIN_LOCKED", + "API_ERROR_SOA_403": "403-soa-unableToParseResponseBody", // G2 + "API_ERROR_NO_ACCOUNT": "accountNotFound", // G2 + "API_ERROR_INVALID_ACCOUNT": "invalidAccount", // G2 + "API_ERROR_INVALID_CREDENTIALS": "InvalidCredentials", // G2 + "API_ERROR_INVALID_TOKEN": "InvalidToken", // G2 + "API_ERROR_PASSWORD_WARNING": "passwordWarning", // G2 + "API_ERROR_TOO_MANY_ATTEMPTS": "tooManyAttempts", // G2 + "API_ERROR_ACCOUNT_LOCKED": "accountLocked", // G2 + "API_ERROR_NO_VEHICLES": "noVehiclesOnAccount", // G2 + "API_ERROR_VEHICLE_SETUP": "VEHICLESETUPERROR", // G2 + "API_ERROR_VEHICLE_NOT_IN_ACCOUNT": "vehicleNotInAccount", // G2 + "API_ERROR_SERVICE_ALREADY_STARTED": "ServiceAlreadyStarted", // G2 + "API_ERROR_G1_NO_SUBSCRIPTION": "SXM40004", // G1 + "API_ERROR_G1_STOLEN_VEHICLE": "SXM40005", // G1 + "API_ERROR_G1_INVALID_PIN": "SXM40006", // G1 + "API_ERROR_G1_SERVICE_ALREADY_STARTED": "SXM40009", // G1 + "API_ERROR_G1_PIN_LOCKED": "SXM40017", // G1 } var APP_ERRORS = map[string]string{ diff --git a/mysubaru.go b/mysubaru.go index 5f0c111..7e8e317 100644 --- a/mysubaru.go +++ b/mysubaru.go @@ -2,6 +2,10 @@ package mysubaru import ( "encoding/json" + "errors" + "fmt" + "log/slog" + "time" ) // Response represents the structure of a response from the MySubaru API. @@ -12,18 +16,59 @@ type Response struct { Data json.RawMessage `json:"data"` // Data struct } -// parse . -// func (r *Response) parse(b []byte, logger *slog.Logger) bool { -// err := json.Unmarshal(b, &r) -// if err != nil { -// logger.Error("error while parsing json", "error", err.Error()) -// return false -// } -// return true -// } +// parse parses the JSON response from the MySubaru API into a Response struct. +func (r *Response) parse(b []byte, logger *slog.Logger) (*Response, error) { + err := json.Unmarshal(b, &r) + if err != nil { + logger.Error("error while parsing json", "error", err.Error()) + return nil, errors.New("error while parsing json: " + err.Error()) + } -// Unmarshal . -// func (r *Response) Unmarshal(b []byte) {} + if !r.Success && r.ErrorCode != "" { + logger.Error("error in response", "errorCode", r.ErrorCode, "dataName", r.DataName) + switch r.ErrorCode { + // G2 API errors + case API_ERRORS["API_ERROR_NO_ACCOUNT"]: + return r, errors.New("error in response: Account not found") + case API_ERRORS["API_ERROR_INVALID_ACCOUNT"]: + return r, errors.New("error in response: Invalid Account") + case API_ERRORS["API_ERROR_INVALID_CREDENTIALS"]: + return r, errors.New("error in response: Invalid Credentials") + case API_ERRORS["API_ERROR_INVALID_TOKEN"]: + return r, errors.New("error in response: Invalid Token") + case API_ERRORS["API_ERROR_PASSWORD_WARNING"]: + return r, errors.New("error in response: Mutiple failed login attempts, password warning") + case API_ERRORS["API_ERROR_TOO_MANY_ATTEMPTS"]: + return r, errors.New("error in response: Too many attempts, please try again later") + case API_ERRORS["API_ERROR_ACCOUNT_LOCKED"]: + return r, errors.New("error in response: Account Locked") + case API_ERRORS["API_ERROR_NO_VEHICLES"]: + return r, errors.New("error in response: No vehicles found for the account") + case API_ERRORS["API_ERROR_VEHICLE_SETUP"]: + return r, errors.New("error in response: Vehicle setup is not complete") + case API_ERRORS["API_ERROR_VEHICLE_NOT_IN_ACCOUNT"]: + return r, errors.New("error in response: Vehicle not in account") + case API_ERRORS["API_ERROR_SERVICE_ALREADY_STARTED"]: + return r, errors.New("error in response: Service already started") + case API_ERRORS["API_ERROR_SOA_403"]: + return r, errors.New("error in response: Unable to parse response body, SOA 403 error") + // G1 API errors + case API_ERRORS["API_ERROR_G1_NO_SUBSCRIPTION"]: + return r, errors.New("error in response: No subscription found for the vehicle") + case API_ERRORS["API_ERROR_G1_STOLEN_VEHICLE"]: + return r, errors.New("error in response: Car is reported as stolen") + case API_ERRORS["API_ERROR_G1_INVALID_PIN"]: + return r, errors.New("error in response: Invalid PIN") + case API_ERRORS["API_ERROR_G1_PIN_LOCKED"]: + return r, errors.New("error in response: PIN is locked") + case API_ERRORS["API_ERROR_G1_SERVICE_ALREADY_STARTED"]: + return r, errors.New("error in response: Service already started") + } + return r, errors.New("error in response: " + r.ErrorCode) + } + + return r, nil +} // Request represents the structure of a request to the MySubaru API. type Request struct { @@ -46,41 +91,83 @@ type Request struct { StartConfiguration *string `json:"startConfiguration,omitempty"` // } -// Account . -type Account struct { - MarketID int `json:"marketId"` - AccountKey int `json:"accountKey"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - ZipCode string `json:"zipCode"` - ZipCode5 string `json:"zipCode5"` - LastLoginDate int64 `json:"lastLoginDate"` - CreatedDate int64 `json:"createdDate"` +// account . +type account struct { + MarketID int `json:"marketId"` + AccountKey int `json:"accountKey"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + ZipCode string `json:"zipCode"` + ZipCode5 string `json:"zipCode5"` + LastLoginDate UnixTime `json:"lastLoginDate"` + CreatedDate UnixTime `json:"createdDate"` } // Customer . type Customer struct { - SessionCustomer string `json:"sessionCustomer"` - Email string `json:"email"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - Zip string `json:"zip"` - OemCustID string `json:"oemCustId"` - Phone string `json:"phone"` + SessionCustomer SessionCustomer `json:"sessionCustomer,omitempty"` // struct | Only by performing a RefreshVehicles request + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Zip string `json:"zip"` + OemCustID string `json:"oemCustId"` + Phone string `json:"phone"` +} + +// SessionCustomer . +type SessionCustomer struct { + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Title string `json:"title,omitempty"` + Suffix string `json:"suffix,omitempty"` + Email string `json:"email"` + Address string `json:"address"` + Address2 string `json:"address2,omitempty"` + City string `json:"city"` + State string `json:"state"` + Zip string `json:"zip"` + CellularPhone string `json:"cellularPhone,omitempty"` + WorkPhone string `json:"workPhone,omitempty"` + HomePhone string `json:"homePhone,omitempty"` + CountryCode string `json:"countryCode"` + RelationshipType any `json:"relationshipType,omitempty"` + Gender string `json:"gender,omitempty"` + DealerCode any `json:"dealerCode,omitempty"` + OemCustID string `json:"oemCustId"` + CreateMysAccount any `json:"createMysAccount,omitempty"` + SourceSystemCode string `json:"sourceSystemCode"` + Vehicles []struct { + Vin string `json:"vin"` + SiebelVehicleRelationship string `json:"siebelVehicleRelationship"` // TM Subscriber | Previous TM Subscriber | Previous Owner + Primary bool `json:"primary"` // true | false + OemCustID string `json:"oemCustId"` // CRM-41PLM-5TYE | 1-8K7OBOJ | 1-8JY3UVS | CRM-44UFUA14-V + Status string `json:"status,omitempty"` // "Active" | "Draft" | "Inactive" + } `json:"vehicles"` + Phone string `json:"phone,omitempty"` + Zip5Digits string `json:"zip5Digits"` + PrimaryPersonalCountry string `json:"primaryPersonalCountry"` +} + +// DataMap . +// "dataName": "dataMap" +type dataMap struct { + Username string `json:"userName"` + Email string `json:"email"` } // SessionData . // "dataName": "sessionData" type SessionData struct { - SessionChanged bool `json:"sessionChanged"` - VehicleInactivated bool `json:"vehicleInactivated"` - Account Account `json:"account"` - ResetPassword bool `json:"resetPassword"` - DeviceID string `json:"deviceId"` - SessionID string `json:"sessionId"` - DeviceRegistered bool `json:"deviceRegistered"` + Account account `json:"account"` PasswordToken string `json:"passwordToken"` + ResetPassword bool `json:"resetPassword"` + SessionID string `json:"sessionId"` + SessionChanged bool `json:"sessionChanged"` + DeviceID string `json:"deviceId"` + DeviceRegistered bool `json:"deviceRegistered"` + RegisteredDevicePermanent bool `json:"registeredDevicePermanent"` Vehicles []VehicleData `json:"vehicles"` + VehicleInactivated bool `json:"vehicleInactivated"` RightToRepairEnabled bool `json:"rightToRepairEnabled"` RightToRepairStates string `json:"rightToRepairStates"` CurrentVehicleIndex int `json:"currentVehicleIndex"` @@ -93,54 +180,53 @@ type SessionData struct { DigitalGlobeTransparentTileService string `json:"digitalGlobeTransparentTileService"` TomtomKey string `json:"tomtomKey"` SatelliteViewEnabled bool `json:"satelliteViewEnabled"` - RegisteredDevicePermanent bool `json:"registeredDevicePermanent"` } // Vehicle . // "dataName": "vehicle" type VehicleData struct { - Customer Customer `json:"customer"` // Customer struct - UserOemCustID string `json:"userOemCustId"` // CRM-631-HQN48K - OemCustID string `json:"oemCustId"` // CRM-631-HQN48K - Active bool `json:"active"` // true | false - Email string `json:"email"` // null | email@address.com - FirstName string `json:"firstName"` // null | First Name - LastName string `json:"lastName"` // null | Last Name - Zip string `json:"zip"` // 12345 - Phone string `json:"phone"` // null | 123-456-7890 - StolenVehicle bool `json:"stolenVehicle"` // true | false - VehicleName string `json:"vehicleName"` // Subaru Outback LXT - Features []string `json:"features"` // "11.6MMAN", "ABS_MIL", "ACCS", "AHBL_MIL", "ATF_MIL", "AWD_MIL", "BSD", "BSDRCT_MIL", "CEL_MIL", "EBD_MIL", "EOL_MIL", "EPAS_MIL", "EPB_MIL", "ESS_MIL", "EYESIGHT", "ISS_MIL", "NAV_TOMTOM", "OPL_MIL", "RAB_MIL", "RCC", "REARBRK", "RES", "RESCC", "RHSF", "RPOI", "RPOIA", "SRH_MIL", "SRS_MIL", "TEL_MIL", "TPMS_MIL", "VDC_MIL", "WASH_MIL", "g2" - Vin string `json:"vin"` // 4Y1SL65848Z411439 - VehicleKey int64 `json:"vehicleKey"` // 3832950 - Nickname string `json:"nickname"` // Subaru Outback LXT - ModelName string `json:"modelName"` // Outback - ModelYear string `json:"modelYear"` // 2020 - ModelCode string `json:"modelCode"` // LDJ - ExtDescrip string `json:"extDescrip"` // Abyss Blue Pearl (ext color) - IntDescrip string `json:"intDescrip"` // Gray (int color) - TransCode string `json:"transCode"` // CVT - EngineSize float64 `json:"engineSize"` // 2.4 - Phev bool `json:"phev"` // null - CachedStateCode string `json:"cachedStateCode"` // NJ - LicensePlate string `json:"licensePlate"` // NJ - LicensePlateState string `json:"licensePlateState"` // ABCDEF - SubscriptionStatus string `json:"subscriptionStatus"` // ACTIVE - SubscriptionFeatures []string `json:"subscriptionFeatures"` // "[ REMOTE ], [ SAFETY ], [ Retail | Finance3 | RetailPHEV ]"" - SubscriptionPlans []string `json:"subscriptionPlans"` // [] - VehicleGeoPosition GeoPosition `json:"vehicleGeoPosition"` // GeoPosition struct - AccessLevel int `json:"accessLevel"` // -1 - VehicleMileage int `json:"vehicleMileage"` // null - CrmRightToRepair bool `json:"crmRightToRepair"` // true | false - AuthorizedVehicle bool `json:"authorizedVehicle"` // false | true - NeedMileagePrompt bool `json:"needMileagePrompt"` // false | true - RemoteServicePinExist bool `json:"remoteServicePinExist"` // true | false - NeedEmergencyContactPrompt bool `json:"needEmergencyContactPrompt"` // false | true - Show3GSunsetBanner bool `json:"show3gSunsetBanner"` // false | true - Provisioned bool `json:"provisioned"` // true | false - TimeZone string `json:"timeZone"` // America/New_York - SunsetUpgraded bool `json:"sunsetUpgraded"` // true | false - PreferredDealer string `json:"preferredDealer"` // null | + Customer Customer `json:"customer"` // Customer struct + OemCustID string `json:"oemCustId"` // CRM-631-HQN48K + UserOemCustID string `json:"userOemCustId"` // CRM-631-HQN48K + Active bool `json:"active"` // true | false + Email string `json:"email"` // null | email@address.com + FirstName string `json:"firstName,omitempty"` // null | First Name + LastName string `json:"lastName,omitempty"` // null | Last Name + Zip string `json:"zip"` // 12345 + Phone string `json:"phone,omitempty"` // null | 123-456-7890 + StolenVehicle bool `json:"stolenVehicle"` // true | false + VehicleName string `json:"vehicleName"` // Subaru Outback LXT + Features []string `json:"features"` // "11.6MMAN", "ABS_MIL", "ACCS", "AHBL_MIL", "ATF_MIL", "AWD_MIL", "BSD", "BSDRCT_MIL", "CEL_MIL", "EBD_MIL", "EOL_MIL", "EPAS_MIL", "EPB_MIL", "ESS_MIL", "EYESIGHT", "ISS_MIL", "NAV_TOMTOM", "OPL_MIL", "RAB_MIL", "RCC", "REARBRK", "RES", "RESCC", "RHSF", "RPOI", "RPOIA", "SRH_MIL", "SRS_MIL", "TEL_MIL", "TPMS_MIL", "VDC_MIL", "WASH_MIL", "g2" + Vin string `json:"vin"` // 4Y1SL65848Z411439 + VehicleKey int64 `json:"vehicleKey"` // 3832950 + Nickname string `json:"nickname"` // Subaru Outback LXT + ModelName string `json:"modelName"` // Outback + ModelYear string `json:"modelYear"` // 2020 + ModelCode string `json:"modelCode"` // LDJ + ExtDescrip string `json:"extDescrip"` // Abyss Blue Pearl (ext color) + IntDescrip string `json:"intDescrip"` // Gray (int color) + TransCode string `json:"transCode"` // CVT + EngineSize float64 `json:"engineSize"` // 2.4 + Phev bool `json:"phev"` // null + CachedStateCode string `json:"cachedStateCode"` // NJ + LicensePlate string `json:"licensePlate"` // NJ + LicensePlateState string `json:"licensePlateState"` // ABCDEF + SubscriptionStatus string `json:"subscriptionStatus"` // ACTIVE + SubscriptionFeatures []string `json:"subscriptionFeatures"` // "[ REMOTE ], [ SAFETY ], [ Retail | Finance3 | RetailPHEV ]"" + SubscriptionPlans []string `json:"subscriptionPlans,omitempty"` // [] + VehicleGeoPosition GeoPosition `json:"vehicleGeoPosition"` // GeoPosition struct + AccessLevel int `json:"accessLevel"` // -1 + VehicleMileage int `json:"vehicleMileage,omitempty"` // null + CrmRightToRepair bool `json:"crmRightToRepair"` // true | false + AuthorizedVehicle bool `json:"authorizedVehicle"` // false | true + NeedMileagePrompt bool `json:"needMileagePrompt"` // false | true + RemoteServicePinExist bool `json:"remoteServicePinExist"` // true | false + NeedEmergencyContactPrompt bool `json:"needEmergencyContactPrompt"` // false | true + Show3GSunsetBanner bool `json:"show3gSunsetBanner"` // false | true + Provisioned bool `json:"provisioned"` // true | false + TimeZone string `json:"timeZone"` // America/New_York + SunsetUpgraded bool `json:"sunsetUpgraded"` // true | false + PreferredDealer string `json:"preferredDealer,omitempty"` // null | } // GeoPosition . @@ -166,65 +252,58 @@ type GeoPosition struct { // } // VehicleStatus . -// "dataName":null -// parts = []{"door", "tire", "tyre", "window"} -// prefix = []{"pressure", "status"} -// suffix = []{"status", "position", "unit", "psi"} -// positions = []{"front", "rear", "sunroof", "boot", "enginehood"} -// subPositions []{"left", "right"} - type VehicleStatus struct { - VehicleId int64 `json:"vhsId"` // + 9969776690 5198812434 - OdometerValue int `json:"odometerValue"` // + 23787 - OdometerValueKm int `json:"odometerValueKilometers"` // + 38273 - EventDate int64 `json:"eventDate"` // + 1701896993000 - EventDateStr string `json:"eventDateStr"` // + 2023-12-06T21:09+0000 - EventDateCarUser int64 `json:"eventDateCarUser"` // + 1701896993000 - EventDateStrCarUser string `json:"eventDateStrCarUser"` // + 2023-12-06T21:09+0000 - Latitude float64 `json:"latitude"` // + 40.700183 - Longitude float64 `json:"longitude"` // + -74.401372 - Heading int `json:"positionHeadingDegree,string"` // + "154" - DistanceToEmptyFuelMiles float64 `json:"distanceToEmptyFuelMiles"` // + 209.4 - DistanceToEmptyFuelKilometers int `json:"distanceToEmptyFuelKilometers"` // + 337 - DistanceToEmptyFuelMiles10s int `json:"distanceToEmptyFuelMiles10s"` // + 210 - DistanceToEmptyFuelKilometers10s int `json:"distanceToEmptyFuelKilometers10s"` // + 340 - AvgFuelConsumptionMpg float64 `json:"avgFuelConsumptionMpg"` // + 18.4 - AvgFuelConsumptionLitersPer100Kilometers float64 `json:"avgFuelConsumptionLitersPer100Kilometers"` // + 12.8 - RemainingFuelPercent int `json:"remainingFuelPercent"` // + 82 - TirePressureFrontLeft int `json:"tirePressureFrontLeft,string,omitempty"` // + "2275" - TirePressureFrontRight int `json:"tirePressureFrontRight,string,omitempty"` // + "2344" - TirePressureRearLeft int `json:"tirePressureRearLeft,string,omitempty"` // + "2413" - TirePressureRearRight int `json:"tirePressureRearRight,string,omitempty"` // + "2344" - TirePressureFrontLeftPsi float64 `json:"tirePressureFrontLeftPsi,string,omitempty"` // + "33" - TirePressureFrontRightPsi float64 `json:"tirePressureFrontRightPsi,string,omitempty"` // + "34" - TirePressureRearLeftPsi float64 `json:"tirePressureRearLeftPsi,string,omitempty"` // + "35" - TirePressureRearRightPsi float64 `json:"tirePressureRearRightPsi,string,omitempty"` // + "34" - TyreStatusFrontLeft string `json:"tyreStatusFrontLeft"` // + "UNKNOWN" - TyreStatusFrontRight string `json:"tyreStatusFrontRight"` // + "UNKNOWN" - TyreStatusRearLeft string `json:"tyreStatusRearLeft"` // + "UNKNOWN" - TyreStatusRearRight string `json:"tyreStatusRearRight"` // + "UNKNOWN" - EvStateOfChargePercent float64 `json:"evStateOfChargePercent,omitempty"` // + null - EvDistanceToEmptyMiles int `json:"evDistanceToEmptyMiles,omitempty"` // + null - EvDistanceToEmptyKilometers int `json:"evDistanceToEmptyKilometers,omitempty"` // + null - EvDistanceToEmptyByStateMiles int `json:"evDistanceToEmptyByStateMiles,omitempty"` // + null - EvDistanceToEmptyByStateKilometers int `json:"evDistanceToEmptyByStateKilometers,omitempty"` // + null - VehicleStateType string `json:"vehicleStateType"` // + "IGNITION_OFF | IGNITION_ON" - WindowFrontLeftStatus string `json:"windowFrontLeftStatus"` // CLOSE | VENTED | OPEN - WindowFrontRightStatus string `json:"windowFrontRightStatus"` // CLOSE | VENTED | OPEN - WindowRearLeftStatus string `json:"windowRearLeftStatus"` // CLOSE | VENTED | OPEN - WindowRearRightStatus string `json:"windowRearRightStatus"` // CLOSE | VENTED | OPEN - WindowSunroofStatus string `json:"windowSunroofStatus"` // CLOSE | SLIDE_PARTLY_OPEN | OPEN | TILT - DoorBootPosition string `json:"doorBootPosition"` // CLOSED | OPEN - DoorEngineHoodPosition string `json:"doorEngineHoodPosition"` // CLOSED | OPEN - DoorFrontLeftPosition string `json:"doorFrontLeftPosition"` // CLOSED | OPEN - DoorFrontRightPosition string `json:"doorFrontRightPosition"` // CLOSED | OPEN - DoorRearLeftPosition string `json:"doorRearLeftPosition"` // CLOSED | OPEN - DoorRearRightPosition string `json:"doorRearRightPosition"` // CLOSED | OPEN - DoorBootLockStatus string `json:"doorBootLockStatus"` // LOCKED | UNLOCKED - DoorFrontLeftLockStatus string `json:"doorFrontLeftLockStatus"` // LOCKED | UNLOCKED - DoorFrontRightLockStatus string `json:"doorFrontRightLockStatus"` // LOCKED | UNLOCKED - DoorRearLeftLockStatus string `json:"doorRearLeftLockStatus"` // LOCKED | UNLOCKED - DoorRearRightLockStatus string `json:"doorRearRightLockStatus"` // LOCKED | UNLOCKED + VehicleId int64 `json:"vhsId"` // + 9969776690 5198812434 + OdometerValue int `json:"odometerValue"` // + 23787 + OdometerValueKm int `json:"odometerValueKilometers"` // + 38273 + EventDate UnixTime `json:"eventDate"` // + 1701896993000 + EventDateStr string `json:"eventDateStr"` // + 2023-12-06T21:09+0000 + EventDateCarUser UnixTime `json:"eventDateCarUser"` // + 1701896993000 + EventDateStrCarUser string `json:"eventDateStrCarUser"` // + 2023-12-06T21:09+0000 + Latitude float64 `json:"latitude"` // + 40.700183 + Longitude float64 `json:"longitude"` // + -74.401372 + Heading int `json:"positionHeadingDegree,string"` // + "154" + DistanceToEmptyFuelMiles float64 `json:"distanceToEmptyFuelMiles"` // + 209.4 + DistanceToEmptyFuelKilometers int `json:"distanceToEmptyFuelKilometers"` // + 337 + DistanceToEmptyFuelMiles10s int `json:"distanceToEmptyFuelMiles10s"` // + 210 + DistanceToEmptyFuelKilometers10s int `json:"distanceToEmptyFuelKilometers10s"` // + 340 + AvgFuelConsumptionMpg float64 `json:"avgFuelConsumptionMpg"` // + 18.4 + AvgFuelConsumptionLitersPer100Kilometers float64 `json:"avgFuelConsumptionLitersPer100Kilometers"` // + 12.8 + RemainingFuelPercent int `json:"remainingFuelPercent"` // + 82 + TirePressureFrontLeft int `json:"tirePressureFrontLeft,string,omitempty"` // + "2275" + TirePressureFrontRight int `json:"tirePressureFrontRight,string,omitempty"` // + "2344" + TirePressureRearLeft int `json:"tirePressureRearLeft,string,omitempty"` // + "2413" + TirePressureRearRight int `json:"tirePressureRearRight,string,omitempty"` // + "2344" + TirePressureFrontLeftPsi float64 `json:"tirePressureFrontLeftPsi,string,omitempty"` // + "33" + TirePressureFrontRightPsi float64 `json:"tirePressureFrontRightPsi,string,omitempty"` // + "34" + TirePressureRearLeftPsi float64 `json:"tirePressureRearLeftPsi,string,omitempty"` // + "35" + TirePressureRearRightPsi float64 `json:"tirePressureRearRightPsi,string,omitempty"` // + "34" + TyreStatusFrontLeft string `json:"tyreStatusFrontLeft"` // + "UNKNOWN" + TyreStatusFrontRight string `json:"tyreStatusFrontRight"` // + "UNKNOWN" + TyreStatusRearLeft string `json:"tyreStatusRearLeft"` // + "UNKNOWN" + TyreStatusRearRight string `json:"tyreStatusRearRight"` // + "UNKNOWN" + EvStateOfChargePercent float64 `json:"evStateOfChargePercent,omitempty"` // + null + EvDistanceToEmptyMiles int `json:"evDistanceToEmptyMiles,omitempty"` // + null + EvDistanceToEmptyKilometers int `json:"evDistanceToEmptyKilometers,omitempty"` // + null + EvDistanceToEmptyByStateMiles int `json:"evDistanceToEmptyByStateMiles,omitempty"` // + null + EvDistanceToEmptyByStateKilometers int `json:"evDistanceToEmptyByStateKilometers,omitempty"` // + null + VehicleStateType string `json:"vehicleStateType"` // + "IGNITION_OFF | IGNITION_ON" + WindowFrontLeftStatus string `json:"windowFrontLeftStatus"` // CLOSE | VENTED | OPEN + WindowFrontRightStatus string `json:"windowFrontRightStatus"` // CLOSE | VENTED | OPEN + WindowRearLeftStatus string `json:"windowRearLeftStatus"` // CLOSE | VENTED | OPEN + WindowRearRightStatus string `json:"windowRearRightStatus"` // CLOSE | VENTED | OPEN + WindowSunroofStatus string `json:"windowSunroofStatus"` // CLOSE | SLIDE_PARTLY_OPEN | OPEN | TILT + DoorBootPosition string `json:"doorBootPosition"` // CLOSED | OPEN + DoorEngineHoodPosition string `json:"doorEngineHoodPosition"` // CLOSED | OPEN + DoorFrontLeftPosition string `json:"doorFrontLeftPosition"` // CLOSED | OPEN + DoorFrontRightPosition string `json:"doorFrontRightPosition"` // CLOSED | OPEN + DoorRearLeftPosition string `json:"doorRearLeftPosition"` // CLOSED | OPEN + DoorRearRightPosition string `json:"doorRearRightPosition"` // CLOSED | OPEN + DoorBootLockStatus string `json:"doorBootLockStatus"` // LOCKED | UNLOCKED + DoorFrontLeftLockStatus string `json:"doorFrontLeftLockStatus"` // LOCKED | UNLOCKED + DoorFrontRightLockStatus string `json:"doorFrontRightLockStatus"` // LOCKED | UNLOCKED + DoorRearLeftLockStatus string `json:"doorRearLeftLockStatus"` // LOCKED | UNLOCKED + DoorRearRightLockStatus string `json:"doorRearRightLockStatus"` // LOCKED | UNLOCKED } // VehicleCondition . @@ -287,6 +366,7 @@ type VehicleCondition struct { // "dataName": "remoteServiceStatus" type ServiceRequest struct { ServiceRequestID string `json:"serviceRequestId,omitempty"` // 4S4BTGND8L3137058_1640294426029_19_@NGTP + Vin string `json:"vin"` // 4S4BTGND8L3137058 Success bool `json:"success"` // false | true // Could be in the false state while the executed request in the progress Cancelled bool `json:"cancelled"` // false | true RemoteServiceType string `json:"remoteServiceType"` // vehicleStatus | condition | locate | unlock | lock | lightsOnly | engineStart | engineStop | phevChargeNow @@ -294,8 +374,7 @@ type ServiceRequest struct { SubState string `json:"subState,omitempty"` // null ErrorCode string `json:"errorCode,omitempty"` // null:null Result json.RawMessage `json:"result,omitempty"` // struct - UpdateTime int64 `json:"updateTime,omitempty"` // timestamp - Vin string `json:"vin"` // 4S4BTGND8L3137058 + UpdateTime UnixTime `json:"updateTime,omitempty"` // timestamp // is empty if the request is started } // climateSettings: [ climateSettings ] @@ -334,3 +413,49 @@ type ErrorResponse struct { ErrorLabel string `json:"errorLabel"` // "404-soa-unableToParseResponseBody" ErrorDescription string `json:"errorDescription,omitempty"` // null } + +// UnixTime is a wrapper around time.Time that allows us to marshal and unmarshal Unix timestamps +type UnixTime struct { + time.Time +} + +// UnmarshalJSON is the method that satisfies the Unmarshaller interface +// Note that it uses a pointer receiver. It needs this because it will be modifying the embedded time.Time instance +func (u *UnixTime) UnmarshalJSON(b []byte) error { + var timestamp int64 + err := json.Unmarshal(b, ×tamp) + if err != nil { + return err + } + u.Time = time.Unix(timestamp, 0) + return nil +} + +// MarshalJSON turns our time.Time back into an int +func (u UnixTime) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf("%d", (u.Time.Unix()))), nil +} + +// CustomTime1 "2021-12-22T13:14:47" is a custom type for unmarshalling time strings +type CustomTime1 struct { + time.Time +} + +// UnmarshalJSON implements the json.Unmarshaler interface +func (ct *CustomTime1) UnmarshalJSON(b []byte) (err error) { + // Use the correct layout string for the desired format + const layout = "2006-01-02T15:04:05" + ct.Time, err = time.Parse(layout, string(b)) + return +} + +// CustomTime2 "2023-04-10T17:50:54+0000" is a custom type for unmarshalling time strings +type CustomTime2 struct { + time.Time +} + +func (ct *CustomTime2) UnmarshalJSON(b []byte) (err error) { + const layout = "2006-01-02T15:04:05-0700" + ct.Time, err = time.Parse(layout, string(b)) // Parse the string using the custom layout + return +} diff --git a/mysubaru_test.go b/mysubaru_test.go new file mode 100644 index 0000000..5aaea69 --- /dev/null +++ b/mysubaru_test.go @@ -0,0 +1,204 @@ +package mysubaru + +import ( + "encoding/json" + "testing" + "time" +) + +func TestUnixTime_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + wantTime time.Time + wantError bool + }{ + { + name: "valid unix timestamp", + input: "1700000000", + wantTime: time.Unix(1700000000, 0), + }, + { + name: "invalid string", + input: "\"notanumber\"", + wantError: true, + }, + { + name: "empty input", + input: "", + wantError: true, + }, + { + name: "float value", + input: "1700000000.123", + wantError: true, + }, + { + name: "zero timestamp", + input: "0", + wantTime: time.Unix(0, 0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ut UnixTime + err := ut.UnmarshalJSON([]byte(tt.input)) + if (err != nil) != tt.wantError { + t.Errorf("UnmarshalJSON() error = %v, wantError %v", err, tt.wantError) + return + } + if !tt.wantError && !ut.Time.Equal(tt.wantTime) { + t.Errorf("UnmarshalJSON() got = %v, want %v", ut.Time, tt.wantTime) + } + }) + } +} + +func TestUnixTime_UnmarshalJSON_withJSONUnmarshal(t *testing.T) { + type testStruct struct { + Time UnixTime `json:"time"` + } + input := `{"time":1700000000}` + var ts testStruct + err := json.Unmarshal([]byte(input), &ts) + if err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + want := time.Unix(1700000000, 0) + if !ts.Time.Time.Equal(want) { + t.Errorf("UnmarshalJSON() got = %v, want %v", ts.Time.Time, want) + } +} +func TestUnixTime_MarshalJSON(t *testing.T) { + tests := []struct { + name string + input time.Time + want string + }{ + { + name: "epoch", + input: time.Unix(0, 0), + want: "0", + }, + { + name: "positive unix time", + input: time.Unix(1700000000, 0), + want: "1700000000", + }, + { + name: "negative unix time", + input: time.Unix(-100, 0), + want: "-100", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ut := UnixTime{Time: tt.input} + got, err := ut.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + if string(got) != tt.want { + t.Errorf("MarshalJSON() = %s, want %s", string(got), tt.want) + } + }) + } +} + +func TestUnixTime_MarshalJSON_withJSONMarshal(t *testing.T) { + type testStruct struct { + Time UnixTime `json:"time"` + } + ts := testStruct{Time: UnixTime{Time: time.Unix(1700000000, 0)}} + b, err := json.Marshal(ts) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + want := `{"time":1700000000}` + if string(b) != want { + t.Errorf("json.Marshal() = %s, want %s", string(b), want) + } +} + +// func TestResponse_parse(t *testing.T) { +// tests := []struct { +// name string +// input string +// wantErr error +// wantCode string +// wantLog string +// }{ +// { +// name: "success response", +// input: `{"success":true,"dataName":"foo","data":{}}`, +// wantErr: nil, +// }, +// { +// name: "invalid json", +// input: `{"success":tru`, +// wantErr: errors.New("error while parsing json:"), +// wantLog: "error while parsing json", +// }, +// { +// name: "API_ERROR_NO_ACCOUNT", +// input: `{"success":false,"errorCode":"noAccount","dataName":"errorResponse","data":{}}`, +// wantErr: errors.New("error in response: Account not found"), +// wantCode: "noAccount", +// wantLog: "error in response", +// }, +// { +// name: "API_ERROR_INVALID_CREDENTIALS", +// input: `{"success":false,"errorCode":"invalidCredentials","dataName":"errorResponse","data":{}}`, +// wantErr: errors.New("error in response: Invalid Credentials"), +// wantCode: "invalidCredentials", +// wantLog: "error in response", +// }, +// { +// name: "API_ERROR_SOA_403", +// input: `{"success":false,"errorCode":"404-soa-unableToParseResponseBody","dataName":"errorResponse","data":{}}`, +// wantErr: errors.New("error in response: Unable to parse response body, SOA 403 error"), +// wantCode: "404-soa-unableToParseResponseBody", +// wantLog: "error in response", +// }, +// { +// name: "unknown error code", +// input: `{"success":false,"errorCode":"somethingElse","dataName":"errorResponse","data":{}}`, +// wantErr: errors.New("error in response: somethingElse"), +// wantCode: "somethingElse", +// wantLog: "error in response", +// }, +// { +// name: "no errorCode but not success", +// input: `{"success":false,"dataName":"errorResponse","data":{}}`, +// wantErr: nil, +// }, +// } + +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// var resp Response +// logger := slog.New(slog.NewTextHandler(nil, nil)) +// got, err := resp.parse([]byte(tt.input), logger) +// if tt.wantErr != nil { +// if err == nil { +// t.Fatalf("expected error, got nil") +// } +// if !contains(err.Error(), tt.wantErr.Error()) { +// t.Errorf("parse() error = %v, want %v", err, tt.wantErr) +// } +// } else if err != nil { +// t.Errorf("parse() unexpected error: %v", err) +// } +// if tt.wantCode != "" && got != nil && got.ErrorCode != tt.wantCode { +// t.Errorf("parse() got.ErrorCode = %v, want %v", got.ErrorCode, tt.wantCode) +// } +// }) +// } +// } + +// // contains is a helper for substring matching. +// func contains(s, substr string) bool { +// return bytes.Contains([]byte(s), []byte(substr)) +// } diff --git a/utils.go b/utils.go index 275cd4b..bdd2d59 100644 --- a/utils.go +++ b/utils.go @@ -1,7 +1,10 @@ package mysubaru import ( + "fmt" "math" + "net/mail" + "reflect" "regexp" "strconv" "strings" @@ -114,6 +117,65 @@ func transcodeDigits(vin string) int { return digitSum } +// emailMasking takes an email address as input and returns a version of the email +// with the username part partially hidden for privacy. Only the first and last +// characters of the username are visible, with the middle characters replaced by asterisks. +// The function validates the email format before processing. +// Returns the obfuscated email or an error if the input is not a valid email address. +func emailMasking(email string) (string, error) { + _, err := mail.ParseAddress(email) + if err != nil { + return "", fmt.Errorf("invalid email address: %s", email) + } + + re1 := regexp.MustCompile(`^(.*?)@(.*)$`) + matches := re1.FindStringSubmatch(email) + + var username, domain string + if len(matches) == 3 { // Expecting the full match, username, and domain + username = matches[1] + domain = matches[2] + } else { + return "", fmt.Errorf("invalid email format: %s", email) + } + + re2 := regexp.MustCompile(`(.)(.*)(.)`) + + replacedString := re2.ReplaceAllStringFunc(username, func(s string) string { + firstChar := string(s[0]) + lastChar := string(s[len(s)-1]) + middleCharsCount := len(s) - 2 + + if middleCharsCount < 0 { // Should not happen with the length check above, but for robustness + return s + } + return firstChar + strings.Repeat("*", middleCharsCount) + lastChar + }) + + return replacedString + "@" + domain, nil +} + +// containsValueInStruct checks if any string field in the given struct 's' contains the specified 'search' substring (case-insensitive). +// It returns true if at least one string field contains the substring, and false otherwise. +// If 's' is not a struct, it returns false. +func containsValueInStruct(s any, search string) bool { + val := reflect.ValueOf(s) + if val.Kind() != reflect.Struct { + return false // Not a struct + } + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + if field.Kind() == reflect.String { + if strings.Contains(strings.ToLower(field.String()), strings.ToLower(search)) { + return true + } + } + } + + return false +} + // timeTrack . // func timeTrack(name string) { // start := time.Now() diff --git a/utils_test.go b/utils_test.go index acc98e5..d338f34 100644 --- a/utils_test.go +++ b/utils_test.go @@ -128,3 +128,89 @@ func TestUrlToGen_NoApiGen(t *testing.T) { t.Errorf("urlToGen(%q, %q) = %q, want %q", url, gen, got, url) } } + +func TestEmailHidder(t *testing.T) { + tests := []struct { + email string + expected string + wantErr bool + }{ + {"alex@example.com", "a**x@example.com", false}, + {"a@example.com", "a@example.com", false}, + {"ab@example.com", "ab@example.com", false}, + {"", "", true}, + {"notanemail", "", true}, + } + + for _, tt := range tests { + got, err := emailMasking(tt.email) + if (err != nil) != tt.wantErr { + t.Errorf("emailHidder(%q) error = %v, wantErr %v", tt.email, err, tt.wantErr) + continue + } + if got != tt.expected { + t.Errorf("emailHidder(%q) = %q, want %q", tt.email, got, tt.expected) + } + } +} +func TestContainsValueInStruct(t *testing.T) { + type TestStruct struct { + Name string + Address string + Age int + Note string + } + tests := []struct { + s any + search string + want bool + }{ + { + s: TestStruct{Name: "Alice", Address: "123 Main St", Age: 30, Note: "VIP customer"}, + search: "alice", + want: true, + }, + { + s: TestStruct{Name: "Bob", Address: "456 Elm St", Age: 25, Note: "Regular"}, + search: "elm", + want: true, + }, + { + s: TestStruct{Name: "Charlie", Address: "789 Oak St", Age: 40, Note: "VIP"}, + search: "vip", + want: true, + }, + { + s: TestStruct{Name: "Diana", Address: "101 Pine St", Age: 22, Note: ""}, + search: "xyz", + want: false, + }, + { + s: TestStruct{Name: "", Address: "", Age: 0, Note: ""}, + search: "", + want: true, // empty string is contained in all strings + }, + { + s: struct{ Foo int }{Foo: 42}, + search: "42", + want: false, + }, + { + s: "not a struct", + search: "struct", + want: false, + }, + { + s: struct{ S string }{S: "CaseInsensitive"}, + search: "caseinsensitive", + want: true, + }, + } + + for i, tt := range tests { + got := containsValueInStruct(tt.s, tt.search) + if got != tt.want { + t.Errorf("Test %d: containsStringInStruct(%#v, %q) = %v, want %v", i, tt.s, tt.search, got, tt.want) + } + } +} diff --git a/vehicle.go b/vehicle.go index 0bbd169..eed726f 100644 --- a/vehicle.go +++ b/vehicle.go @@ -792,6 +792,7 @@ func (v *Vehicle) GetFeaturesList() { } // executeServiceRequest +// Executes a service request to the Subaru API and handles the response. func (v *Vehicle) executeServiceRequest(params map[string]string, reqUrl, pollingUrl string, ch chan string, attempt int) error { var maxAttempts = 15