Skip to content

Commit 49af86e

Browse files
authored
Merge pull request #308 from drdrak3/feat-control-companion-param
feat(proxy): optional companion param on /control/reserve and /control/mode
2 parents 2edc813 + d460995 commit 49af86e

5 files changed

Lines changed: 287 additions & 18 deletions

File tree

RELEASE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# RELEASE NOTES
22

3+
## v0.15.10 - Combined Reserve + Mode Control Endpoint
4+
5+
* feat(proxy): Optional companion parameters on `/control/reserve` and `/control/mode` POST endpoints to update both reserve and mode in a single `set_operation()` call (#308)
6+
* `/control/reserve` now accepts optional `mode=$MODE` parameter — calls `set_operation(level, mode)` instead of `set_reserve(level)`
7+
* `/control/mode` now accepts optional `level=$RESERVE` parameter — calls `set_operation(level, mode)` instead of `set_mode(mode)`
8+
* Prevents duplicate Tesla audit-log entries caused by calling set_reserve + set_mode separately
9+
* Invalid companion values return a 400 error without making any Powerwall call (no silent fallback)
10+
* Full backward compatibility: omitting the companion parameter preserves original behavior
11+
* Added unit tests for all code paths (legacy single-value, combined, and invalid companion)
12+
* Updated proxy README with combined-request examples
13+
* Bump library version to `0.15.10`
14+
315
## v0.15.9 - Improved Connection Error Diagnostics
416

517
* Fix: Promote connection failure messages from `debug` to `error`/`warning` log level so users see actionable diagnostics without enabling debug mode (#160)

proxy/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,9 @@ APIs
326326
327327
* Use `GET` method to read and `POST` to set.
328328
* Mode: `/control/mode` value=$MODE token=$PW_CONTROL_SECRET
329+
* Optional `level=$RESERVE` to update reserve in the same request (single `set_operation()` call).
329330
* Reserve: `/control/reserve` value=$RESERVE token=$PW_CONTROL_SECRET
331+
* Optional `mode=$MODE` to update mode in the same request (single `set_operation()` call).
330332
331333
Examples
332334
@@ -341,6 +343,10 @@ curl -X POST -d "value=$MODE&token=$PW_CONTROL_SECRET" http://localhost:8675/con
341343
# Set Reserve
342344
curl -X POST -d "value=$RESERVE&token=$PW_CONTROL_SECRET" http://localhost:8675/control/reserve
343345
346+
# Set Mode AND Reserve in a single request (either form works)
347+
curl -X POST -d "value=$MODE&level=$RESERVE&token=$PW_CONTROL_SECRET" http://localhost:8675/control/mode
348+
curl -X POST -d "value=$RESERVE&mode=$MODE&token=$PW_CONTROL_SECRET" http://localhost:8675/control/reserve
349+
344350
# Enable Grid Charging (true/false)
345351
curl -X POST -d "value=true&token=$PW_CONTROL_SECRET" http://localhost:8675/control/grid_charging
346352

proxy/server.py

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@
128128
PyPowerwallFleetAPIInvalidPayload,
129129
)
130130

131-
BUILD = "t89"
131+
BUILD = "t90"
132132
ALLOWLIST = [
133133
"/api/status",
134134
"/api/site_info/site_name",
@@ -1030,15 +1030,48 @@ def do_POST(self):
10301030
safe_pw_call(pw_control.get_reserve) or 0
10311031
)
10321032
elif value.isdigit():
1033-
result = safe_pw_call(
1034-
pw_control.set_reserve, int(value)
1035-
)
1036-
message = json.dumps(
1037-
result
1038-
if result is not None
1039-
else {"error": "Failed to set reserve"}
1040-
)
1041-
log.info(f"Control Command: Set Reserve to {value}")
1033+
# Optional companion `mode` parameter lets a single
1034+
# /control/reserve POST update both reserve and mode
1035+
# in one set_operation() invocation. Without it, a
1036+
# caller that wants to change both has to POST to
1037+
# /control/reserve AND /control/mode, which causes
1038+
# set_operation() to run twice -- writing the Tesla
1039+
# /backup and /operation endpoints twice each and
1040+
# producing duplicate audit-log entries. Omitting
1041+
# the parameter preserves the original behaviour.
1042+
mode = query_params.get("mode", [""])[0]
1043+
if mode in [
1044+
"self_consumption",
1045+
"backup",
1046+
"autonomous",
1047+
]:
1048+
result = safe_pw_call(
1049+
pw_control.set_operation,
1050+
int(value),
1051+
mode,
1052+
)
1053+
message = json.dumps(
1054+
result
1055+
if result is not None
1056+
else {"error": "Failed to set reserve+mode"}
1057+
)
1058+
log.info(
1059+
f"Control Command: Set Reserve to {value} (mode={mode})"
1060+
)
1061+
elif mode:
1062+
message = (
1063+
'{"error": "Control Command Mode Invalid"}'
1064+
)
1065+
else:
1066+
result = safe_pw_call(
1067+
pw_control.set_reserve, int(value)
1068+
)
1069+
message = json.dumps(
1070+
result
1071+
if result is not None
1072+
else {"error": "Failed to set reserve"}
1073+
)
1074+
log.info(f"Control Command: Set Reserve to {value}")
10421075
else:
10431076
message = (
10441077
'{"error": "Control Command Value Invalid"}'
@@ -1054,13 +1087,35 @@ def do_POST(self):
10541087
"backup",
10551088
"autonomous",
10561089
]:
1057-
result = safe_pw_call(pw_control.set_mode, value)
1058-
message = json.dumps(
1059-
result
1060-
if result is not None
1061-
else {"error": "Failed to set mode"}
1062-
)
1063-
log.info(f"Control Command: Set Mode to {value}")
1090+
# Optional companion `level` parameter -- see the
1091+
# comment in the /control/reserve branch above.
1092+
level = query_params.get("level", [""])[0]
1093+
if level.isdigit():
1094+
result = safe_pw_call(
1095+
pw_control.set_operation,
1096+
int(level),
1097+
value,
1098+
)
1099+
message = json.dumps(
1100+
result
1101+
if result is not None
1102+
else {"error": "Failed to set reserve+mode"}
1103+
)
1104+
log.info(
1105+
f"Control Command: Set Mode to {value} (level={level})"
1106+
)
1107+
elif level:
1108+
message = (
1109+
'{"error": "Control Command Level Invalid"}'
1110+
)
1111+
else:
1112+
result = safe_pw_call(pw_control.set_mode, value)
1113+
message = json.dumps(
1114+
result
1115+
if result is not None
1116+
else {"error": "Failed to set mode"}
1117+
)
1118+
log.info(f"Control Command: Set Mode to {value}")
10641119
else:
10651120
message = (
10661121
'{"error": "Control Command Value Invalid"}'
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""Tests for /control/reserve and /control/mode POST handlers.
2+
3+
Covers the single-value (legacy) form and the optional companion-parameter
4+
form (mode= on /control/reserve, level= on /control/mode) added to let a
5+
caller update both reserve and mode in a single set_operation() invocation.
6+
"""
7+
import json
8+
import unittest
9+
from http import HTTPStatus
10+
from io import BytesIO
11+
from unittest.mock import Mock, patch
12+
13+
from proxy.tests.test_csv_endpoints import UnittestHandler, common_patches
14+
15+
16+
SECRET = "test-secret"
17+
18+
19+
def _encode(params):
20+
return "&".join(f"{k}={v}" for k, v in params.items()).encode("utf-8")
21+
22+
23+
def _passthrough_safe_pw_call(fn, *args, **kwargs):
24+
return fn(*args, **kwargs)
25+
26+
27+
class BaseDoPostTest(unittest.TestCase):
28+
def setUp(self):
29+
self.handler = UnittestHandler()
30+
self.handler.command = "POST"
31+
32+
def _post(self, path, params):
33+
body = _encode(params)
34+
self.handler.path = path
35+
self.handler.rfile = BytesIO(body)
36+
self.handler.headers = {"Content-Length": str(len(body))}
37+
self.handler.do_POST()
38+
39+
def _response_body(self):
40+
return self.handler.wfile.getvalue().decode("utf-8")
41+
42+
def _make_pw_control(self, set_reserve=None, set_mode=None, set_operation=None):
43+
pw_control = Mock()
44+
pw_control.client = Mock() # not None -> passes cloud-mode connectivity check
45+
pw_control.set_reserve.return_value = set_reserve if set_reserve is not None else {"set_reserve": "ok"}
46+
pw_control.set_mode.return_value = set_mode if set_mode is not None else {"set_mode": "ok"}
47+
pw_control.set_operation.return_value = set_operation if set_operation is not None else {"set_operation": "ok"}
48+
return pw_control
49+
50+
51+
class TestControlReserve(BaseDoPostTest):
52+
"""POST /control/reserve — single-value and combined-with-mode forms."""
53+
54+
@common_patches
55+
@patch("proxy.server.control_secret", SECRET)
56+
@patch("proxy.server.pw")
57+
@patch("proxy.server.pw_control")
58+
@patch("proxy.server.safe_pw_call", side_effect=_passthrough_safe_pw_call)
59+
def test_value_only_calls_set_reserve(self, _proxystats_lock, mock_safe, mock_pw_control, mock_pw):
60+
"""value= alone -> set_reserve(level), set_operation not called (legacy behaviour)."""
61+
pw_control = self._make_pw_control()
62+
mock_pw_control.client = pw_control.client
63+
mock_pw_control.set_reserve = pw_control.set_reserve
64+
mock_pw_control.set_operation = pw_control.set_operation
65+
mock_pw.tedapi = None
66+
67+
self._post("/control/reserve", {"value": "50", "token": SECRET})
68+
69+
pw_control.set_reserve.assert_called_once_with(50)
70+
pw_control.set_operation.assert_not_called()
71+
self.handler.send_response.assert_called_with(HTTPStatus.OK)
72+
self.assertEqual(json.loads(self._response_body()), {"set_reserve": "ok"})
73+
74+
@common_patches
75+
@patch("proxy.server.control_secret", SECRET)
76+
@patch("proxy.server.pw")
77+
@patch("proxy.server.pw_control")
78+
@patch("proxy.server.safe_pw_call", side_effect=_passthrough_safe_pw_call)
79+
def test_value_with_valid_mode_calls_set_operation(self, _proxystats_lock, mock_safe, mock_pw_control, mock_pw):
80+
"""value= + valid mode= -> set_operation(level, mode), neither set_reserve nor set_mode."""
81+
pw_control = self._make_pw_control()
82+
mock_pw_control.client = pw_control.client
83+
mock_pw_control.set_reserve = pw_control.set_reserve
84+
mock_pw_control.set_mode = pw_control.set_mode
85+
mock_pw_control.set_operation = pw_control.set_operation
86+
mock_pw.tedapi = None
87+
88+
self._post(
89+
"/control/reserve",
90+
{"value": "5", "mode": "self_consumption", "token": SECRET},
91+
)
92+
93+
pw_control.set_operation.assert_called_once_with(5, "self_consumption")
94+
pw_control.set_reserve.assert_not_called()
95+
pw_control.set_mode.assert_not_called()
96+
self.handler.send_response.assert_called_with(HTTPStatus.OK)
97+
self.assertEqual(json.loads(self._response_body()), {"set_operation": "ok"})
98+
99+
@common_patches
100+
@patch("proxy.server.control_secret", SECRET)
101+
@patch("proxy.server.pw")
102+
@patch("proxy.server.pw_control")
103+
@patch("proxy.server.safe_pw_call", side_effect=_passthrough_safe_pw_call)
104+
def test_value_with_invalid_mode_returns_error(self, _proxystats_lock, mock_safe, mock_pw_control, mock_pw):
105+
"""value= + invalid mode= -> 400 error, no Powerwall call (no silent fallback to set_reserve)."""
106+
pw_control = self._make_pw_control()
107+
mock_pw_control.client = pw_control.client
108+
mock_pw_control.set_reserve = pw_control.set_reserve
109+
mock_pw_control.set_operation = pw_control.set_operation
110+
mock_pw.tedapi = None
111+
112+
self._post(
113+
"/control/reserve",
114+
{"value": "5", "mode": "garbage", "token": SECRET},
115+
)
116+
117+
pw_control.set_reserve.assert_not_called()
118+
pw_control.set_operation.assert_not_called()
119+
self.handler.send_response.assert_called_with(HTTPStatus.BAD_REQUEST)
120+
self.assertIn("error", json.loads(self._response_body()))
121+
122+
123+
class TestControlMode(BaseDoPostTest):
124+
"""POST /control/mode — single-value and combined-with-level forms."""
125+
126+
@common_patches
127+
@patch("proxy.server.control_secret", SECRET)
128+
@patch("proxy.server.pw")
129+
@patch("proxy.server.pw_control")
130+
@patch("proxy.server.safe_pw_call", side_effect=_passthrough_safe_pw_call)
131+
def test_value_only_calls_set_mode(self, _proxystats_lock, mock_safe, mock_pw_control, mock_pw):
132+
"""value= alone -> set_mode(mode), set_operation not called (legacy behaviour)."""
133+
pw_control = self._make_pw_control()
134+
mock_pw_control.client = pw_control.client
135+
mock_pw_control.set_mode = pw_control.set_mode
136+
mock_pw_control.set_operation = pw_control.set_operation
137+
mock_pw.tedapi = None
138+
139+
self._post("/control/mode", {"value": "backup", "token": SECRET})
140+
141+
pw_control.set_mode.assert_called_once_with("backup")
142+
pw_control.set_operation.assert_not_called()
143+
self.handler.send_response.assert_called_with(HTTPStatus.OK)
144+
self.assertEqual(json.loads(self._response_body()), {"set_mode": "ok"})
145+
146+
@common_patches
147+
@patch("proxy.server.control_secret", SECRET)
148+
@patch("proxy.server.pw")
149+
@patch("proxy.server.pw_control")
150+
@patch("proxy.server.safe_pw_call", side_effect=_passthrough_safe_pw_call)
151+
def test_value_with_valid_level_calls_set_operation(self, _proxystats_lock, mock_safe, mock_pw_control, mock_pw):
152+
"""value= + valid level= -> set_operation(level, mode), neither set_mode nor set_reserve."""
153+
pw_control = self._make_pw_control()
154+
mock_pw_control.client = pw_control.client
155+
mock_pw_control.set_mode = pw_control.set_mode
156+
mock_pw_control.set_reserve = pw_control.set_reserve
157+
mock_pw_control.set_operation = pw_control.set_operation
158+
mock_pw.tedapi = None
159+
160+
self._post(
161+
"/control/mode",
162+
{"value": "backup", "level": "80", "token": SECRET},
163+
)
164+
165+
pw_control.set_operation.assert_called_once_with(80, "backup")
166+
pw_control.set_mode.assert_not_called()
167+
pw_control.set_reserve.assert_not_called()
168+
self.handler.send_response.assert_called_with(HTTPStatus.OK)
169+
self.assertEqual(json.loads(self._response_body()), {"set_operation": "ok"})
170+
171+
@common_patches
172+
@patch("proxy.server.control_secret", SECRET)
173+
@patch("proxy.server.pw")
174+
@patch("proxy.server.pw_control")
175+
@patch("proxy.server.safe_pw_call", side_effect=_passthrough_safe_pw_call)
176+
def test_value_with_invalid_level_returns_error(self, _proxystats_lock, mock_safe, mock_pw_control, mock_pw):
177+
"""value= + non-numeric level= -> 400 error, no Powerwall call (no silent fallback to set_mode)."""
178+
pw_control = self._make_pw_control()
179+
mock_pw_control.client = pw_control.client
180+
mock_pw_control.set_mode = pw_control.set_mode
181+
mock_pw_control.set_operation = pw_control.set_operation
182+
mock_pw.tedapi = None
183+
184+
self._post(
185+
"/control/mode",
186+
{"value": "backup", "level": "not-a-number", "token": SECRET},
187+
)
188+
189+
pw_control.set_mode.assert_not_called()
190+
pw_control.set_operation.assert_not_called()
191+
self.handler.send_response.assert_called_with(HTTPStatus.BAD_REQUEST)
192+
self.assertIn("error", json.loads(self._response_body()))
193+
194+
195+
if __name__ == "__main__":
196+
unittest.main()

pypowerwall/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
from json import JSONDecodeError
9090
from typing import Optional, Union
9191

92-
version_tuple = (0, 15, 9)
92+
version_tuple = (0, 15, 10)
9393
version = __version__ = '%d.%d.%d' % version_tuple
9494
__author__ = 'jasonacox'
9595

0 commit comments

Comments
 (0)