Skip to content

Instantly share code, notes, and snippets.

@koleson
Last active February 4, 2025 18:22
Show Gist options
  • Save koleson/d78da48497575455b9be6a3c96951bd1 to your computer and use it in GitHub Desktop.
Save koleson/d78da48497575455b9be6a3c96951bd1 to your computer and use it in GitHub Desktop.
Script to set SunPower SunVault ESS discharge mode via SunPower GraphQL API
#!/usr/bin/env zsh
# Created 5 April 2023 by Kiel Oleson - [email protected] - @kielo
# updated 6 July 2024
#
# example usage: ./sunpower_ess_mode_set.sh -m SELF_CONSUMPTION -r 0.04 -s A_123456 -u [email protected] -p SuperSecret1!
#
# README/NOTES: https://gist.github.com/koleson/db9df38ef6051715d743e572acebdd4d
#
# default reserve level to provide if none is specified at command line
reserve_level=0.07
# provided as-is without warranty :)
help_message="not implemented lol - source code is your best bet"
while (( $# )); do
case $1 in
-h|--help) printf "${help_message}" && return ;;
-u|--username) shift; username=$1 ;;
-p|--password) shift; password=$1 ;;
-m|--mode) shift; new_mode=$1 ;;
-r|--reserve) shift; provided_reserve_level=$1 ;;
-s|--site) shift; site_id=$1 ;;
-v|--verbose) flag_verbose=true ;;
-*) opterr $1 && return 2 ;;
*) positional+=("${@[@]}"); break ;;
esac
shift
done
case $username in
"")
echo "Username (-u/--username) is required."
exit 1
;;
esac
case $site_id in
A_*)
# echo "Site ID ${site_id} looks good to me"
;;
"")
echo "Site ID (-s/--site) required."
exit 1
;;
*)
echo "Site ID ${site_id} looks sus - usually they start with \"A_\", but I'll roll with it."
;;
esac
# from sunpower dl_cgi swagger - valid modes, my assumed descriptions:
# "UNKNOWN",
# "STANDBY", Probably the pre-Permission To Operate state
# "MANUAL_CHARGE", Unclear - if you know what this does, let me know!
# "MANUAL_DCM", DCM = "Demand Charge Management?"
# "DCM", DCM = "Demand Charge Management?"
# "TARIFF_OPTIMIZER", Unclear - if you know what this does, let me know!
# "ENERGY_ARBITRAGE", ("Cost Savings" in SunPower app)
# "SELF_CONSUMPTION", ("Self-Supply" in SunPower app)
# "BACKUP_ONLY", Only discharge when off-grid.
# "HECO_ZERO_EXPORT" Hawaiian Electric Company gives no credit for exports 9AM-4PM.
case $new_mode in
STANDBY|MANUAL_CHARGE|MANUAL_DCM|DCM|TARIFF_OPTIMIZER|ENERGY_ARBITRAGE|SELF_CONSUMPTION|BACKUP_ONLY|HECO_ZERO_EXPORT)
echo "changing ESS to mode ${new_mode}..."
;;
*)
echo "invalid target ESS state ${new_mode} - see script source for valid modes";
exit 1;;
esac
case $provided_reserve_level in
[01]|0.*)
# echo "valid reserve level provided"
reserve_level="${provided_reserve_level}"
;;
[-][0-9]*([.][0-9]*) | [0-9]*)
echo "valid number but not a valid reserve level - needs to be between 0 and 1, inclusive"
exit 1
;;
"")
echo "valid reserve level not provided but i guess that's ok? - using default of ${reserve_level}"
;;
*)
echo "reserve value provided (${provided_reserve_level}) but not valid - needs to be a float between 0 and 1, inclusive"
exit 1
;;
esac
shift $((OPTIND-1))
# we can actually generate the login and graphql mutations with just the provided info - better for debugging to do so before attempting the http requests.
login_data=$(jq --null-input --arg username "${username}" --arg password "${password}" '{"remember": "true", "username": $username, "password": $password}')
# echo "login data: ${login_data}"
params_json=$(jq --null-input --arg siteid "${site_id}" --arg mode "${new_mode}" --argjson reservelevel $reserve_level '{"siteKey": $siteid, "mode": $mode, "reserveLevel": $reservelevel}')
mutation='"mutation ChangeBatteryMode($params: NewBatteryModeInput!) {\n changeBatteryMode(params: $params) {\n siteKey\n label\n value\n }\n}\n"'
change_query='{ "query": '"${mutation}"', "variables": { "params": '"${params_json}"'}}'
# echo "params json: ${params_json}"
# echo "query: ${change_query}"
# when debugging json/query generation, it's often useful to bail out early
# TODO: add dry-run flag to control this. kmo 5 apr 2023 18h39
# exit 20
# in theory, we could reuse the access token / renew it if needed / only log in if needed
# TODO: persist and reuse auth data
echo "logging in..."
login_result=$( {
curl -s 'https://edp-api.edp.sunpower.com/v1/auth/okta/signin' \
-X POST \
-H 'Host: edp-api.edp.sunpower.com' \
-H 'User-Agent: sunpower-graphql (1.0) CFNetwork/1406.0.4 Darwin/22.4.0' \
-H 'Connection: keep-alive' \
-H 'Accept: */*' \
-H 'Accept-Language: en-US,en;q=0.9' \
-H "Content-Length: ${#login_data}" \
-H 'Authorization: Bearer undefined' \
-H 'Content-Type: application/json; charset=utf-8' \
-d "${login_data}"
} )
# echo "login result: ${login_result}"
access_token=$( echo "${login_result}" | jq -r .access_token )
case $access_token in
"")
echo "access token doesn't look very valid... exiting"
echo "response to auth attempt: ${login_result}"
exit 1
;;
esac
echo "logged in."
# now, actually update the mode
echo "updating ESS mode..."
update_result=$( { curl -s 'https://edp-api-graphql.edp.sunpower.com/graphql' \
-H 'Accept-Encoding: gzip, deflate, br' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Connection: keep-alive' \
-H 'Origin: altair://-' \
-H "Authorization: Bearer ${access_token}" \
--data-binary "${change_query}" \
--compressed } )
# confirm updates by echoing relevant part of API response
if jq -e . >/dev/null 2>&1 <<< "${update_result}" ; then
# echo "Parsed JSON successfully and got something other than false/null"
# echo "${update_result}"
resulting_mode=$(echo "${update_result}" | jq .data.changeBatteryMode )
echo "success - resulting mode info: ${resulting_mode}"
else
echo "failed to parse response JSON"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment