Skip to content

Commit c3a4d87

Browse files
committed
allow empty passwords for authentication and improve websocket state management for proper reconnection
1 parent 77a155d commit c3a4d87

6 files changed

Lines changed: 56 additions & 6 deletions

File tree

openevsehttp/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,8 @@ async def ws_start(self) -> None:
192192

193193
async def _start_listening(self):
194194
"""Websocket setup."""
195-
if not self.websocket:
196-
_LOGGER.debug("Websocket not initialized, creating...")
195+
if not self.websocket or self.websocket.state == STATE_STOPPED:
196+
_LOGGER.debug("Websocket not initialized or stopped, creating...")
197197
self.websocket = OpenEVSEWebsocket(
198198
self.url, self._update_status, self._user, self._pwd, self._session
199199
)

openevsehttp/requester.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async def process_request(
5959
raise MissingMethod
6060
method = method.lower()
6161

62-
if self._user and self._pwd:
62+
if self._user:
6363
auth = aiohttp.BasicAuth(self._user, self._pwd)
6464

6565
if data is not None and rapi is not None:

openevsehttp/websocket.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ async def running(self):
8484
await self._set_state(STATE_STARTING)
8585
auth = None
8686

87-
if self._user and self._password:
87+
if self._user:
8888
auth = aiohttp.BasicAuth(self._user, self._password)
8989

9090
try:

tests/test_client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1600,7 +1600,7 @@ async def pending_stub(*args, **kwargs):
16001600
caplog.at_level(logging.DEBUG),
16011601
):
16021602
await charger.ws_start()
1603-
assert "Websocket not initialized, creating..." in caplog.text
1603+
assert "Websocket not initialized or stopped, creating..." in caplog.text
16041604

16051605
# Now task is active. Call ws_start AGAIN to trigger orphan check.
16061606
# Force _ws_listening to False so it attempts setup again
@@ -1708,6 +1708,17 @@ async def noop():
17081708
with pytest.raises(AlreadyListening):
17091709
await charger.ws_start()
17101710

1711+
# Verify recreation of stopped websocket (fix for reuse bug)
1712+
charger.websocket.state = STATE_STOPPED
1713+
first_ws = charger.websocket
1714+
await charger.ws_start()
1715+
assert charger.websocket is not first_ws
1716+
# Cleanup tasks from the new websocket
1717+
for task in list(charger.tasks):
1718+
task.cancel()
1719+
await asyncio.gather(*charger.tasks, return_exceptions=True)
1720+
charger.tasks = set()
1721+
17111722
# Verify idempotence on a fresh instance
17121723
charger_fresh = OpenEVSE(SERVER_URL)
17131724
await charger_fresh.ws_disconnect() # Disconnect immediately

tests/test_override.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,18 @@ async def test_override_failure_logic(mock_aioclient):
566566
f"http://{SERVER_URL}/config", status=200, body='{"version": "4.1.0"}'
567567
)
568568
mock_aioclient.get(
569-
f"http://{SERVER_URL}/override", status=200, body='{"state": "disabled"}'
569+
f"http://{SERVER_URL}/override",
570+
status=200,
571+
body=json.dumps(
572+
{
573+
"state": "disabled",
574+
"charge_current": 0,
575+
"max_current": 0,
576+
"energy_limit": 0,
577+
"time_limit": 0,
578+
"auto_release": True,
579+
}
580+
),
570581
)
571582

572583
charger = OpenEVSE(SERVER_URL)

tests/test_websocket.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,34 @@ async def test_auth_failure(ws_client, mock_callback):
100100
)
101101

102102

103+
@pytest.mark.asyncio
104+
async def test_auth_empty_password(mock_callback):
105+
"""Verify that an empty password still triggers BasicAuth in websocket."""
106+
client = OpenEVSEWebsocket(SERVER_URL, mock_callback, user="admin", password="")
107+
108+
mock_ws = MagicMock()
109+
mock_ws.__aenter__ = AsyncMock(return_value=mock_ws)
110+
mock_ws.__aexit__ = AsyncMock(return_value=None)
111+
112+
async def async_iter():
113+
yield MagicMock(type=aiohttp.WSMsgType.CLOSED)
114+
115+
mock_ws.__aiter__.side_effect = async_iter
116+
117+
with (
118+
patch("aiohttp.ClientSession.ws_connect", return_value=mock_ws) as mock_connect,
119+
patch("asyncio.sleep", new_callable=AsyncMock),
120+
):
121+
await client.running()
122+
123+
args, kwargs = mock_connect.call_args
124+
auth = kwargs.get("auth")
125+
assert auth is not None
126+
assert auth.login == "admin"
127+
assert auth.password == ""
128+
await client.session.close()
129+
130+
103131
@pytest.mark.asyncio
104132
async def test_connection_error_retry(ws_client, mock_callback):
105133
"""Test connection retry logic."""

0 commit comments

Comments
 (0)