Skip to content

Commit b662776

Browse files
MaStrclaudeCopilotCopilot
authored
Fix: MQTT - TLS/SSL Implementation (#397)
* fix: repair TLS support for MQTT and evcc plugin Both MqttApi and EvccApi had broken TLS code that could never work: the condition checked config['tls'] is True but then tried to index the same boolean as a dict (config['tls']['ca_certs']). The config format uses flat keys cafile/certfile/keyfile at the same level as tls: true. Fix reads from those keys and drops the now-unused tls_version/cert_reqs fields (paho picks sensible defaults). Also updated the example config to remove the defunct tls_version key and add clarifying comments. Verified end-to-end: Mosquitto 2 with mutual TLS on port 8883, both MqttApi and EvccApi connect successfully with tls: true. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add CLI section to CLAUDE.md with --one-shot flag Documents the correct flag name to avoid confusion with --once. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: validate TLS config and add unit tests for mqtt_api and evcc_api Addresses Copilot review comments on PR #397: - Raise ValueError with a clear message if tls: true but cafile is missing, or if certfile/keyfile are only partially provided. - Add TestTlsSetup in test_mqtt_api.py covering: TLS disabled, CA-only, mutual TLS, missing cafile, and unpaired cert/key. - Add tests/batcontrol/test_evcc_api.py with the same coverage for EvccApi; both use unittest.mock.patch so no broker is needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: update mqtt-api.md to document working TLS configuration * docs: update evcc-connection.md to document working TLS configuration --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 3ef21a8 commit b662776

8 files changed

Lines changed: 263 additions & 29 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ draft GitHub release + version-bump PR; the pushed tag triggers the Docker build
7272
`main` is bumped to the next `dev` version and the release is promoted into the HA add-on repo
7373
(`release-addon` skill there).
7474

75+
## CLI
76+
77+
```
78+
python -m batcontrol [--config PATH] [--one-shot]
79+
```
80+
81+
- `--one-shot` — fetch data, run the control loop once, then exit. Useful for testing. Not `--once`.
82+
7583
## Known Pitfalls
7684

7785
- ASCII-only in source code — no umlauts, special chars, emoji, even in log messages. Does not

config/batcontrol_config_dummy.yaml

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,12 @@ mqtt:
160160
password: password
161161
retry_attempts: 5 # optional, default: 5
162162
retry_delay: 10 # seconds, optional, default: 10
163+
# TLS: set tls to true and provide paths to CA cert and (optional) client cert/key
164+
# for mutual TLS. Use port 8883 for TLS-secured brokers.
163165
tls: false
164-
cafile: /etc/ssl/certs/ca-certificates.crt
165-
certfile: /etc/ssl/certs/client.crt
166-
keyfile: /etc/ssl/certs/client.key
167-
tls_version: tlsv1.2
166+
cafile: /etc/ssl/certs/ca-certificates.crt # path to CA certificate (required when tls: true)
167+
certfile: /etc/ssl/certs/client.crt # path to client certificate (optional, for mutual TLS)
168+
keyfile: /etc/ssl/certs/client.key # path to client private key (optional, for mutual TLS)
168169
auto_discover_enable: true # enables mqtt auto discover => https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery
169170
auto_discover_topic: homeassistant # base topic path for auto discover config messages - default 'homeassistant' -> https://www.home-assistant.io/integrations/mqtt/#discovery-options
170171

@@ -259,11 +260,12 @@ evcc:
259260
block_battery_while_charging: true
260261
username: user
261262
password: password
263+
# TLS: set tls to true and provide paths to CA cert and (optional) client cert/key
264+
# for mutual TLS. Use port 8883 for TLS-secured brokers.
262265
tls: false
263-
cafile: /etc/ssl/certs/ca-certificates.crt
264-
certfile: /etc/ssl/certs/client.crt
265-
keyfile: /etc/ssl/certs/client.key
266-
tls_version: tlsv1.2
266+
cafile: /etc/ssl/certs/ca-certificates.crt # path to CA certificate (required when tls: true)
267+
certfile: /etc/ssl/certs/client.crt # path to client certificate (optional, for mutual TLS)
268+
keyfile: /etc/ssl/certs/client.key # path to client private key (optional, for mutual TLS)
267269
# Optional:
268270
# Choose which topic should deliver the limit for the battery
269271
# below this limit the battery will be locked

docs/integrations/evcc-connection.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,26 @@ evcc:
5555

5656
### TLS/SSL Support
5757

58-
> ⚠️ **Note**: TLS/SSL support is currently **untested and known to be non-functional** (same limitation as in the [MQTT API](mqtt-api.md)). Keep MQTT traffic on a trusted local network.
58+
Batcontrol supports TLS-encrypted MQTT connections to the evcc broker. Configure TLS with flat keys at the same level as other evcc connection parameters:
59+
60+
```yaml
61+
evcc:
62+
broker: mqtt.home.local
63+
port: 8883
64+
tls: true
65+
cafile: /etc/ssl/certs/ca-certificates.crt # path to CA certificate (required when tls: true)
66+
certfile: /etc/ssl/certs/client.crt # path to client certificate (optional, for mutual TLS)
67+
keyfile: /etc/ssl/certs/client.key # path to client private key (optional, for mutual TLS)
68+
```
69+
70+
| Parameter | Type | Description |
71+
|-----------|------|-------------|
72+
| `tls` | boolean | Enable TLS encryption (default: `false`). Use port 8883 for TLS-secured brokers |
73+
| `cafile` | string | Path to the CA certificate file. **Required** when `tls: true` |
74+
| `certfile` | string | Path to the client certificate file. Optional; required for mutual TLS |
75+
| `keyfile` | string | Path to the client private key file. Optional; required for mutual TLS (must be set together with `certfile`) |
76+
77+
> **Note**: `certfile` and `keyfile` must both be set or both be absent — partial mutual TLS configuration raises an error at startup.
5978

6079
### Battery Management Options
6180

@@ -237,6 +256,6 @@ When using both batcontrol and evcc with Home Assistant:
237256
## Security Considerations
238257

239258
- Use authentication for production MQTT brokers
240-
- TLS encryption is currently not functional (see above) — keep MQTT traffic on a trusted local network
259+
- Use TLS encryption (`tls: true` with a valid `cafile`) when the MQTT broker is not on a fully trusted local network
241260
- Ensure MQTT user has appropriate topic permissions
242261
- Keep MQTT credentials secure and unique

docs/integrations/mqtt-api.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,26 @@ mqtt:
4444

4545
### TLS/SSL Configuration
4646

47-
> ⚠️ **Note**: TLS/SSL support is currently **untested and known to be non-functional**: the implementation expects the certificate options nested below `tls`, while the enable check expects a boolean — these requirements contradict each other. Track progress or report your use case in the project issues before relying on TLS.
47+
To connect to a TLS-secured MQTT broker, set `tls: true` and provide the path to your CA certificate. For mutual TLS, also supply a client certificate and key.
48+
49+
```yaml
50+
mqtt:
51+
enabled: true
52+
broker: mqtt.example.com
53+
port: 8883
54+
topic: house/batcontrol
55+
tls: true
56+
cafile: /etc/ssl/certs/ca-certificates.crt # required when tls: true
57+
certfile: /etc/ssl/certs/client.crt # optional, for mutual TLS
58+
keyfile: /etc/ssl/certs/client.key # optional, for mutual TLS
59+
```
60+
61+
| Parameter | Type | Default | Description |
62+
|-----------|------|---------|-------------|
63+
| `tls` | boolean | `false` | Enable TLS/SSL for the broker connection |
64+
| `cafile` | string | — | Path to the CA certificate file (required when `tls: true`) |
65+
| `certfile` | string | — | Path to the client certificate (optional, for mutual TLS) |
66+
| `keyfile` | string | — | Path to the client private key (optional, for mutual TLS) |
4867

4968
## Home Assistant Auto-Discovery
5069

@@ -279,6 +298,6 @@ This will provide detailed information about MQTT connections, published message
279298
## Security Considerations
280299

281300
- Always use authentication (`username`/`password`) in production
282-
- TLS encryption is currently not functional (see above) — keep MQTT traffic on a trusted local network
301+
- Use TLS encryption (`tls: true`) when the MQTT broker is reachable over an untrusted network
283302
- Limit MQTT user permissions to only necessary topics
284303
- Use strong, unique passwords for MQTT authentication

src/batcontrol/evcc_api.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,23 @@ def __init__(self, config: dict):
115115
if 'username' in config and 'password' in config:
116116
self.client.username_pw_set(config['username'], config['password'])
117117

118-
# TLS , not tested yet
119-
if config['tls'] is True:
118+
if config.get('tls') is True:
119+
cafile = config.get('cafile')
120+
if not cafile:
121+
raise ValueError(
122+
'evcc: tls is enabled but cafile is missing or empty'
123+
)
124+
certfile = config.get('certfile') or None
125+
keyfile = config.get('keyfile') or None
126+
if bool(certfile) != bool(keyfile):
127+
raise ValueError(
128+
'evcc: certfile and keyfile must both be set or both be absent'
129+
)
120130
self.client.tls_set(
121-
config['tls']['ca_certs'],
122-
config['tls']['certfile'],
123-
config['tls']['keyfile'],
124-
cert_reqs=config['tls']['cert_reqs'],
125-
tls_version=config['tls']['tls_version'],
126-
ciphers=config['tls']['ciphers']
131+
ca_certs=cafile,
132+
certfile=certfile,
133+
keyfile=keyfile,
134+
ciphers=config.get('ciphers') or None,
127135
)
128136

129137
# Register callback functions, survives reconnects

src/batcontrol/mqtt_api.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,23 @@ def __init__(self, config: dict, interval_minutes: int = 60):
105105
'offline',
106106
retain=True)
107107

108-
# TLS , not tested yet
109-
if config['tls'] is True:
108+
if config.get('tls') is True:
109+
cafile = config.get('cafile')
110+
if not cafile:
111+
raise ValueError(
112+
'mqtt: tls is enabled but cafile is missing or empty'
113+
)
114+
certfile = config.get('certfile') or None
115+
keyfile = config.get('keyfile') or None
116+
if bool(certfile) != bool(keyfile):
117+
raise ValueError(
118+
'mqtt: certfile and keyfile must both be set or both be absent'
119+
)
110120
self.client.tls_set(
111-
config['tls']['ca_certs'],
112-
config['tls']['certfile'],
113-
config['tls']['keyfile'],
114-
cert_reqs=config['tls']['cert_reqs'],
115-
tls_version=config['tls']['tls_version'],
116-
ciphers=config['tls']['ciphers']
121+
ca_certs=cafile,
122+
certfile=certfile,
123+
keyfile=keyfile,
124+
ciphers=config.get('ciphers') or None,
117125
)
118126

119127
self.client.on_connect = self.on_connect

tests/batcontrol/test_evcc_api.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Tests for EvccApi TLS configuration."""
2+
from unittest.mock import patch
3+
4+
import pytest
5+
6+
from batcontrol.evcc_api import EvccApi
7+
8+
9+
BASE_CONFIG = {
10+
'broker': 'localhost',
11+
'port': 8883,
12+
'status_topic': 'evcc/status',
13+
'loadpoint_topic': ['evcc/loadpoints/1/charging'],
14+
'block_battery_while_charging': True,
15+
}
16+
17+
18+
def _make_config(**overrides):
19+
return {**BASE_CONFIG, **overrides}
20+
21+
22+
class TestTlsSetup:
23+
"""EvccApi.__init__ must configure TLS correctly and reject bad configs."""
24+
25+
def test_tls_disabled_does_not_call_tls_set(self):
26+
with patch('batcontrol.evcc_api.mqtt.Client') as MockClient:
27+
client = MockClient.return_value
28+
cfg = _make_config(tls=False)
29+
EvccApi(cfg)
30+
client.tls_set.assert_not_called()
31+
32+
def test_tls_true_calls_tls_set_with_cafile(self):
33+
with patch('batcontrol.evcc_api.mqtt.Client') as MockClient:
34+
client = MockClient.return_value
35+
cfg = _make_config(tls=True, cafile='/etc/ssl/ca.crt')
36+
EvccApi(cfg)
37+
client.tls_set.assert_called_once_with(
38+
ca_certs='/etc/ssl/ca.crt',
39+
certfile=None,
40+
keyfile=None,
41+
ciphers=None,
42+
)
43+
44+
def test_tls_true_passes_mutual_tls_params(self):
45+
with patch('batcontrol.evcc_api.mqtt.Client') as MockClient:
46+
client = MockClient.return_value
47+
cfg = _make_config(
48+
tls=True,
49+
cafile='/etc/ssl/ca.crt',
50+
certfile='/etc/ssl/client.crt',
51+
keyfile='/etc/ssl/client.key',
52+
)
53+
EvccApi(cfg)
54+
client.tls_set.assert_called_once_with(
55+
ca_certs='/etc/ssl/ca.crt',
56+
certfile='/etc/ssl/client.crt',
57+
keyfile='/etc/ssl/client.key',
58+
ciphers=None,
59+
)
60+
61+
def test_tls_missing_cafile_raises_valueerror(self):
62+
with patch('batcontrol.evcc_api.mqtt.Client'):
63+
cfg = _make_config(tls=True)
64+
with pytest.raises(ValueError, match='cafile'):
65+
EvccApi(cfg)
66+
67+
def test_tls_certfile_without_keyfile_raises_valueerror(self):
68+
with patch('batcontrol.evcc_api.mqtt.Client'):
69+
cfg = _make_config(
70+
tls=True,
71+
cafile='/etc/ssl/ca.crt',
72+
certfile='/etc/ssl/client.crt',
73+
)
74+
with pytest.raises(ValueError, match='certfile and keyfile'):
75+
EvccApi(cfg)
76+
77+
def test_tls_keyfile_without_certfile_raises_valueerror(self):
78+
with patch('batcontrol.evcc_api.mqtt.Client'):
79+
cfg = _make_config(
80+
tls=True,
81+
cafile='/etc/ssl/ca.crt',
82+
keyfile='/etc/ssl/client.key',
83+
)
84+
with pytest.raises(ValueError, match='certfile and keyfile'):
85+
EvccApi(cfg)

tests/batcontrol/test_mqtt_api.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for MqttApi._handle_message, focusing on bytes payload decoding."""
2-
from unittest.mock import MagicMock, call
2+
from unittest.mock import MagicMock, call, patch
3+
4+
import pytest
35

46
from batcontrol.core import Batcontrol
57
from batcontrol.logic import PeakShavingConfig
@@ -498,6 +500,89 @@ def test_skips_publish_when_disconnected(self):
498500
api.client.publish.assert_not_called()
499501

500502

503+
class TestTlsSetup:
504+
"""MqttApi.__init__ must configure TLS correctly and reject bad configs."""
505+
506+
BASE_CONFIG = {
507+
'topic': 'house/batcontrol',
508+
'broker': 'localhost',
509+
'port': 8883,
510+
'retry_attempts': 1,
511+
'retry_delay': 0,
512+
}
513+
514+
def _make_config(self, **overrides):
515+
return {**self.BASE_CONFIG, **overrides}
516+
517+
def test_tls_disabled_does_not_call_tls_set(self):
518+
with patch('batcontrol.mqtt_api.mqtt.Client') as MockClient:
519+
client = MockClient.return_value
520+
client.is_connected.return_value = True
521+
cfg = self._make_config(tls=False)
522+
MqttApi(cfg)
523+
client.tls_set.assert_not_called()
524+
525+
def test_tls_true_calls_tls_set_with_cafile(self):
526+
with patch('batcontrol.mqtt_api.mqtt.Client') as MockClient:
527+
client = MockClient.return_value
528+
client.is_connected.return_value = True
529+
cfg = self._make_config(
530+
tls=True,
531+
cafile='/etc/ssl/ca.crt',
532+
)
533+
MqttApi(cfg)
534+
client.tls_set.assert_called_once_with(
535+
ca_certs='/etc/ssl/ca.crt',
536+
certfile=None,
537+
keyfile=None,
538+
ciphers=None,
539+
)
540+
541+
def test_tls_true_passes_mutual_tls_params(self):
542+
with patch('batcontrol.mqtt_api.mqtt.Client') as MockClient:
543+
client = MockClient.return_value
544+
client.is_connected.return_value = True
545+
cfg = self._make_config(
546+
tls=True,
547+
cafile='/etc/ssl/ca.crt',
548+
certfile='/etc/ssl/client.crt',
549+
keyfile='/etc/ssl/client.key',
550+
)
551+
MqttApi(cfg)
552+
client.tls_set.assert_called_once_with(
553+
ca_certs='/etc/ssl/ca.crt',
554+
certfile='/etc/ssl/client.crt',
555+
keyfile='/etc/ssl/client.key',
556+
ciphers=None,
557+
)
558+
559+
def test_tls_missing_cafile_raises_valueerror(self):
560+
with patch('batcontrol.mqtt_api.mqtt.Client'):
561+
cfg = self._make_config(tls=True)
562+
with pytest.raises(ValueError, match='cafile'):
563+
MqttApi(cfg)
564+
565+
def test_tls_certfile_without_keyfile_raises_valueerror(self):
566+
with patch('batcontrol.mqtt_api.mqtt.Client'):
567+
cfg = self._make_config(
568+
tls=True,
569+
cafile='/etc/ssl/ca.crt',
570+
certfile='/etc/ssl/client.crt',
571+
)
572+
with pytest.raises(ValueError, match='certfile and keyfile'):
573+
MqttApi(cfg)
574+
575+
def test_tls_keyfile_without_certfile_raises_valueerror(self):
576+
with patch('batcontrol.mqtt_api.mqtt.Client'):
577+
cfg = self._make_config(
578+
tls=True,
579+
cafile='/etc/ssl/ca.crt',
580+
keyfile='/etc/ssl/client.key',
581+
)
582+
with pytest.raises(ValueError, match='certfile and keyfile'):
583+
MqttApi(cfg)
584+
585+
501586
class TestPublishSolarActive:
502587
def test_publishes_true_to_correct_topic(self):
503588
api = _make_solar_publish_stub()

0 commit comments

Comments
 (0)