diff --git a/README.md b/README.md index 7cc3fef..c53d053 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ More detailed documentation of every endpoint will come.. - Supported parameters: - `startDate` (optional, use canonical UTC format in RFC3339) - `endDate` (optional, use canonical UTC format in RFC3339) +- GET `/api/v1/cars/:CarID/charges/current` - GET `/api/v1/cars/:CarID/charges/:ChargeID` - GET `/api/v1/cars/:CarID/command` - POST `/api/v1/cars/:CarID/command/:Command` diff --git a/src/v1_TeslaMateAPICarsChargesCurrent.go b/src/v1_TeslaMateAPICarsChargesCurrent.go new file mode 100644 index 0000000..9ff4bb3 --- /dev/null +++ b/src/v1_TeslaMateAPICarsChargesCurrent.go @@ -0,0 +1,466 @@ +package main + +import ( + "database/sql" + "time" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" +) + +const ( + maxChargeInactivityThresholdMinutes = 15 +) + +// TeslaMateAPICarsChargesCurrentV1 func +func TeslaMateAPICarsChargesCurrentV1(c *gin.Context) { + + // define error messages + var ( + CarsChargesCurrentError1 = "Unable to load current charge." + CarsChargesCurrentError2 = "Unable to load current charge details." + CarsChargesCurrentError3 = "No active charging in progress." + ) + + // getting CarID param from URL + CarID := convertStringToInteger(c.Param("CarID")) + + // creating structs for /cars//charges/current + // Car struct - child of Data + type Car struct { + CarID int `json:"car_id"` // smallint + CarName NullString `json:"car_name"` // text (nullable) + } + // BatteryDetails struct - child of Charge + type BatteryDetails struct { + StartBatteryLevel int `json:"start_battery_level"` // int + CurrentBatteryLevel int `json:"current_battery_level"` // int + } + // PreferredRange struct - child of Charge + type PreferredRange struct { + StartRange float64 `json:"start_range"` // float64 + CurrentRange float64 `json:"current_range"` // float64 + AddedRange float64 `json:"added_range"` // float64 + } + // ChargerDetails struct - child of ChargeDetails + type ChargerDetails struct { + ChargerActualCurrent int `json:"charger_actual_current"` // int + ChargerPhases int `json:"charger_phases"` // int + ChargerPilotCurrent int `json:"charger_pilot_current"` // int + ChargerPower int `json:"charger_power"` // int + ChargerVoltage int `json:"charger_voltage"` // int + } + // FastChargerInfo struct - child of ChargeDetails + type FastChargerInfo struct { + FastChargerPresent bool `json:"fast_charger_present"` // bool + FastChargerBrand *string `json:"fast_charger_brand,omitempty"` // string or null + FastChargerType *string `json:"fast_charger_type,omitempty"` // string or null + } + // BatteryInfo struct - child of ChargeDetails + type BatteryInfo struct { + RatedBatteryRange float64 `json:"rated_battery_range"` // float64 + BatteryHeater bool `json:"battery_heater"` // bool + BatteryHeaterOn bool `json:"battery_heater_on"` // bool + BatteryHeaterNoPower NullBool `json:"battery_heater_no_power"` // bool + } + // ChargeDetails struct - child of Charge + type ChargeDetails struct { + DetailID int `json:"detail_id"` // integer + Date string `json:"date"` // string + BatteryLevel int `json:"battery_level"` // int + UsableBatteryLevel int `json:"usable_battery_level"` // int + ChargeEnergyAdded float64 `json:"charge_energy_added"` // float64 + NotEnoughPowerToHeat NullBool `json:"not_enough_power_to_heat"` // bool + ChargerDetails ChargerDetails `json:"charger_details"` // struct + BatteryInfo BatteryInfo `json:"battery_info"` // struct + ConnChargeCable interface{} `json:"conn_charge_cable,omitempty"` // string or null + FastChargerInfo FastChargerInfo `json:"fast_charger_info"` // struct + OutsideTemp float64 `json:"outside_temp"` // float64 + } + // Charge struct - child of Data + type Charge struct { + ChargeID int `json:"charge_id"` // int + StartDate string `json:"start_date"` // string + IsCharging bool `json:"is_charging"` // bool + Address string `json:"address"` // string + ChargeEnergyAdded float64 `json:"charge_energy_added"` // float64 + Cost float64 `json:"cost"` // float64 + DurationMin int `json:"duration_min"` // int + DurationStr string `json:"duration_str"` // string + BatteryDetails BatteryDetails `json:"battery_details"` // BatteryDetails + RatedRange PreferredRange `json:"rated_range"` // PreferredRange + OutsideTempAvg float64 `json:"outside_temp_avg"` // float64 + Odometer float64 `json:"odometer"` // float64 + ChargeDetails []ChargeDetails `json:"charge_details"` // struct + } + // TeslaMateUnits struct - child of Data + type TeslaMateUnits struct { + UnitsLength string `json:"unit_of_length"` // string + UnitsTemperature string `json:"unit_of_temperature"` // string + } + // Data struct - child of JSONData + type Data struct { + Car Car `json:"car"` + Charge Charge `json:"charge"` + TeslaMateUnits TeslaMateUnits `json:"units"` + } + // JSONData struct - main + type JSONData struct { + Data Data `json:"data"` + } + + // creating required vars + var ( + CarName NullString + charge Charge + ChargeDetailsData []ChargeDetails + UnitsLength, UnitsTemperature string + isCharging bool + ) + + // Create temp vars to handle NULL values in the database + var ( + startRatedRange, currentRatedRange sql.NullFloat64 + startBatteryLevel, currentBatteryLevel sql.NullInt64 + chargeEnergyAdded, cost sql.NullFloat64 + outsideTempAvg sql.NullFloat64 + odometer sql.NullFloat64 + durationMin sql.NullFloat64 + durationStr, address sql.NullString + ) + + // Construct the query with the preferred range setting + query := ` + SELECT + charging_processes.id AS charge_id, + start_date, + COALESCE(geofence.name, CONCAT_WS(', ', COALESCE(address.name, nullif(CONCAT_WS(' ', address.road, address.house_number), '')), address.city)) AS address, + (SELECT charge_energy_added FROM charges WHERE charging_process_id = charging_processes.id ORDER BY id DESC LIMIT 1) AS charge_energy_added, + COALESCE(cost, 0) AS cost, + (SELECT rated_battery_range_km FROM charges WHERE charging_process_id = charging_processes.id ORDER BY id ASC LIMIT 1) AS start_rated_range, + (SELECT rated_battery_range_km FROM charges WHERE charging_process_id = charging_processes.id ORDER BY id DESC LIMIT 1) AS current_rated_range, + (SELECT battery_level FROM charges WHERE charging_process_id = charging_processes.id ORDER BY date ASC LIMIT 1) AS start_battery_level, + (SELECT battery_level FROM charges WHERE charging_process_id = charging_processes.id ORDER BY id DESC LIMIT 1) AS current_battery_level, + EXTRACT(EPOCH FROM (COALESCE(end_date, NOW()) - start_date))/60 AS duration_min, + TO_CHAR((EXTRACT(EPOCH FROM (COALESCE(end_date, NOW()) - start_date))/60 * INTERVAL '1 minute'), 'HH24:MI') as duration_str, + (SELECT outside_temp FROM charges WHERE charging_process_id = charging_processes.id ORDER BY id DESC LIMIT 1) AS outside_temp_avg, + position.odometer as odometer, + (SELECT unit_of_length FROM settings LIMIT 1) as unit_of_length, + (SELECT unit_of_temperature FROM settings LIMIT 1) as unit_of_temperature, + cars.name, + end_date IS NULL AS is_charging + FROM charging_processes + LEFT JOIN cars ON car_id = cars.id + LEFT JOIN addresses address ON address_id = address.id + LEFT JOIN positions position ON position_id = position.id + LEFT JOIN geofences geofence ON geofence_id = geofence.id + WHERE charging_processes.car_id=$1 + ORDER BY end_date IS NULL DESC, start_date DESC + LIMIT 1;` + + row := db.QueryRow(query, CarID) + + // Scanning row and putting values into the temp vars to handle NULLs + err := row.Scan( + &charge.ChargeID, + &charge.StartDate, + &address, + &chargeEnergyAdded, + &cost, + &startRatedRange, + ¤tRatedRange, + &startBatteryLevel, + ¤tBatteryLevel, + &durationMin, + &durationStr, + &outsideTempAvg, + &odometer, + &UnitsLength, + &UnitsTemperature, + &CarName, + &isCharging, + ) + + switch err { + case sql.ErrNoRows: + TeslaMateAPIHandleErrorResponse(c, "TeslaMateAPICarsChargesCurrentV1", "No current charge found.", "No rows were returned") + return + case nil: + // nothing wrong.. continuing + break + default: + TeslaMateAPIHandleErrorResponse(c, "TeslaMateAPICarsChargesCurrentV1", CarsChargesCurrentError1, err.Error()) + return + } + + charge.IsCharging = isCharging + if address.Valid { + charge.Address = address.String + } else { + charge.Address = "Unknown" + } + + if chargeEnergyAdded.Valid { + charge.ChargeEnergyAdded = chargeEnergyAdded.Float64 + } + + if cost.Valid { + charge.Cost = cost.Float64 + } + + if startRatedRange.Valid { + charge.RatedRange.StartRange = startRatedRange.Float64 + } + + if currentRatedRange.Valid { + charge.RatedRange.CurrentRange = currentRatedRange.Float64 + } + + if startBatteryLevel.Valid { + charge.BatteryDetails.StartBatteryLevel = int(startBatteryLevel.Int64) + } + + if currentBatteryLevel.Valid { + charge.BatteryDetails.CurrentBatteryLevel = int(currentBatteryLevel.Int64) + } + + if durationMin.Valid { + charge.DurationMin = int(durationMin.Float64) // Convert float64 to int + } + + if durationStr.Valid { + charge.DurationStr = durationStr.String + } + + if outsideTempAvg.Valid { + charge.OutsideTempAvg = outsideTempAvg.Float64 + } + + if odometer.Valid { + charge.Odometer = odometer.Float64 + } + + // Converting values based on settings UnitsLength + if UnitsLength == "mi" { + charge.RatedRange.StartRange = kilometersToMiles(charge.RatedRange.StartRange) + charge.RatedRange.CurrentRange = kilometersToMiles(charge.RatedRange.CurrentRange) + charge.RatedRange.AddedRange = kilometersToMiles(charge.RatedRange.AddedRange) + charge.Odometer = kilometersToMiles(charge.Odometer) + } + // Converting values based on settings UnitsTemperature + if UnitsTemperature == "F" && outsideTempAvg.Valid { + charge.OutsideTempAvg = celsiusToFahrenheit(charge.OutsideTempAvg) + } + + // Adjusting to timezone differences from UTC to be user-specific + charge.StartDate = getTimeInTimeZone(charge.StartDate) + + // Getting detailed charge data from database + detailsQuery := ` + SELECT + id AS detail_id, + date, + battery_level, + usable_battery_level, + charge_energy_added, + not_enough_power_to_heat, + COALESCE(charger_actual_current, 0) as charger_actual_current, + COALESCE(charger_phases, 0) AS charger_phases, + COALESCE(charger_pilot_current, 0) as charger_pilot_current, + COALESCE(charger_power, 0) as charger_power, + COALESCE(charger_voltage, 0) as charger_voltage, + rated_battery_range_km AS rated_battery_range, + battery_heater, + battery_heater_on, + battery_heater_no_power, + conn_charge_cable, + fast_charger_present, + fast_charger_brand, + fast_charger_type, + outside_temp + FROM charges + WHERE charging_process_id=$1 + ORDER BY id DESC;` + rows, err := db.Query(detailsQuery, charge.ChargeID) + + // Checking for errors in query + if err != nil { + TeslaMateAPIHandleErrorResponse(c, "TeslaMateAPICarsChargesCurrentV1", CarsChargesCurrentError2, err.Error()) + return + } + + // Defer closing rows + defer rows.Close() + + // Looping through all results + for rows.Next() { + // Create temp variables to handle NULL values + var ( + detailBatteryLevel, detailUsableBatteryLevel sql.NullInt64 + detailChargeEnergyAdded, detailRatedBatteryRange, detailOutsideTemp sql.NullFloat64 + detailConnChargeCable, detailFastChargerType sql.NullString + detailFastChargerBrand sql.NullString + ) + + // Creating chargedetails object based on struct + chargedetails := ChargeDetails{} + + // Scanning row and putting values into temporary variables + err = rows.Scan( + &chargedetails.DetailID, + &chargedetails.Date, + &detailBatteryLevel, + &detailUsableBatteryLevel, + &detailChargeEnergyAdded, + &chargedetails.NotEnoughPowerToHeat, + &chargedetails.ChargerDetails.ChargerActualCurrent, + &chargedetails.ChargerDetails.ChargerPhases, + &chargedetails.ChargerDetails.ChargerPilotCurrent, + &chargedetails.ChargerDetails.ChargerPower, + &chargedetails.ChargerDetails.ChargerVoltage, + &detailRatedBatteryRange, + &chargedetails.BatteryInfo.BatteryHeater, + &chargedetails.BatteryInfo.BatteryHeaterOn, + &chargedetails.BatteryInfo.BatteryHeaterNoPower, + &detailConnChargeCable, + &chargedetails.FastChargerInfo.FastChargerPresent, + &detailFastChargerBrand, + &detailFastChargerType, + &detailOutsideTemp, + ) + + // Handle NULL values + if detailBatteryLevel.Valid { + chargedetails.BatteryLevel = int(detailBatteryLevel.Int64) + } + + if detailUsableBatteryLevel.Valid { + chargedetails.UsableBatteryLevel = int(detailUsableBatteryLevel.Int64) + } + + if detailChargeEnergyAdded.Valid { + chargedetails.ChargeEnergyAdded = detailChargeEnergyAdded.Float64 + } + + if detailRatedBatteryRange.Valid { + chargedetails.BatteryInfo.RatedBatteryRange = detailRatedBatteryRange.Float64 + } + + // Properly handle NULL values for string fields by using interface{} + if detailConnChargeCable.Valid { + chargedetails.ConnChargeCable = detailConnChargeCable.String + } else { + chargedetails.ConnChargeCable = nil + } + + // Fix for fast_charger_brand and fast_charger_type when "" + if detailFastChargerBrand.Valid && detailFastChargerBrand.String != "" { + chargedetails.FastChargerInfo.FastChargerBrand = &detailFastChargerBrand.String + } else { + chargedetails.FastChargerInfo.FastChargerBrand = nil + } + + if detailFastChargerType.Valid && detailFastChargerType.String != "" { + chargedetails.FastChargerInfo.FastChargerType = &detailFastChargerType.String + } else { + chargedetails.FastChargerInfo.FastChargerType = nil + } + + if detailOutsideTemp.Valid { + chargedetails.OutsideTemp = detailOutsideTemp.Float64 + } + + // Converting values based on settings UnitsLength + if UnitsLength == "mi" && detailRatedBatteryRange.Valid { + chargedetails.BatteryInfo.RatedBatteryRange = kilometersToMiles(chargedetails.BatteryInfo.RatedBatteryRange) + } + + // Converting values based on settings UnitsTemperature + if UnitsTemperature == "F" && detailOutsideTemp.Valid { + chargedetails.OutsideTemp = celsiusToFahrenheit(chargedetails.OutsideTemp) + } + + // Adjusting to timezone differences from UTC to be user-specific + chargedetails.Date = getTimeInTimeZone(chargedetails.Date) + + chargedetails.ChargerDetails.ChargerPhases = normalizeChargerPhases(chargedetails.ChargerDetails.ChargerPhases) + + // Checking for errors after scanning + if err != nil { + TeslaMateAPIHandleErrorResponse(c, "TeslaMateAPICarsChargesCurrentV1", CarsChargesCurrentError2, err.Error()) + return + } + + // Appending chargedetails to ChargeDetailsData + ChargeDetailsData = append(ChargeDetailsData, chargedetails) + } + + // Checking for errors in the rows result + err = rows.Err() + if err != nil { + TeslaMateAPIHandleErrorResponse(c, "TeslaMateAPICarsChargesCurrentV1", CarsChargesCurrentError2, err.Error()) + return + } + + // Check if the charge details array contains any entries + if len(ChargeDetailsData) > 0 { + // Parse the date of the most recent charge detail + latestDetailDate, err := time.Parse(time.RFC3339, ChargeDetailsData[0].Date) + if err != nil { + TeslaMateAPIHandleErrorResponse(c, "TeslaMateAPICarsChargesCurrentV1", CarsChargesCurrentError2, "Error parsing charge detail date") + return + } + + // Calculate time elapsed since the most recent detail + timeElapsed := time.Since(latestDetailDate) + + // If the most recent detail is more than 15 minutes old, consider it incomplete/not current + if timeElapsed.Minutes() > maxChargeInactivityThresholdMinutes { + TeslaMateAPIHandleErrorResponse(c, "TeslaMateAPICarsChargesCurrentV1", CarsChargesCurrentError3, "No active charging in progress. There are incomplete charges but last update was more than 15 minutes ago.") + return + } + } + + // Set the ChargeDetails in the charge + charge.ChargeDetails = ChargeDetailsData + + if charge.RatedRange.StartRange == 0 && len(ChargeDetailsData) > 0 { + charge.RatedRange.StartRange = ChargeDetailsData[len(ChargeDetailsData)-1].BatteryInfo.RatedBatteryRange + } + + if addedRange := charge.RatedRange.CurrentRange - charge.RatedRange.StartRange; addedRange > 0 { + charge.RatedRange.AddedRange = addedRange + } else { + charge.RatedRange.AddedRange = 0 + } + + // Build the data-blob + jsonData := JSONData{ + Data{ + Car: Car{ + CarID: CarID, + CarName: CarName, + }, + Charge: charge, + TeslaMateUnits: TeslaMateUnits{ + UnitsLength: UnitsLength, + UnitsTemperature: UnitsTemperature, + }, + }, + } + + // Return jsonData + TeslaMateAPIHandleSuccessResponse(c, "TeslaMateAPICarsChargesCurrentV1", jsonData) +} + +// normalizeChargerPhases converts phase values to valid configurations. +// Both phase 2 and 3 are normalized to 3. +// All other values default to 1 (single-phase). +func normalizeChargerPhases(phases int) int { + switch phases { + case 2, 3: + return 3 + default: + return 1 + } +} diff --git a/src/webserver.go b/src/webserver.go index 76dbab5..7e3580c 100644 --- a/src/webserver.go +++ b/src/webserver.go @@ -140,6 +140,7 @@ func main() { // v1 /api/v1/cars/:CarID/charges endpoints v1.GET("/cars/:CarID/charges", TeslaMateAPICarsChargesV1) + v1.GET("/cars/:CarID/charges/current", TeslaMateAPICarsChargesCurrentV1) v1.GET("/cars/:CarID/charges/:ChargeID", TeslaMateAPICarsChargesDetailsV1) // v1 /api/v1/cars/:CarID/command endpoints