Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This is a Python based repository providing an application for controlling batte
- Remove excessive whitespaces.
- Follow PEP8 standards. Use autopep8 for that.
- Check against pylint. Target score is like 9.0-9.5, if you can achieve 10, do it.
- Build against Python 3.11 primarily, ensure compatibility with versions 3.9 to 3.13.
- Build against Python 3.14 primarily, ensure compatibility with versions 3.11 to 3.14.
- Run all tests with `run_tests.sh` .
- Create new parameters in `config/batcontrol_config_dummy.yaml` .

Expand Down Expand Up @@ -45,7 +45,7 @@ This is a Python based repository providing an application for controlling batte
5. Lay test scripts for verification and simple testing into the folder `scripts`.
6. Never commit content of `tmp`.
7. User documentation lives in the `docs/` folder and is published via MkDocs to https://mastr.github.io/batcontrol/ — add or update pages there and register new pages in `mkdocs.yml`.
8. Ensure compatibility with supported Python versions (3.9 to 3.13)
8. Ensure compatibility with supported Python versions (3.11 to 3.14)
9. A change that affects which inverter mode is chosen, or why (`core.py`, `logic/default.py`,
`logic/next.py`), must stay traceable: add or extend a `DecisionRecord` with a plain-language
explanation (`logic/decision_trace.py`, `logic/decision_records.py`). This feeds the MQTT
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pylint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
contents: read
strategy:
matrix:
python-version: ["3.9", "3.10" , "3.11" , "3.12", "3.13"]
python-version: ["3.11" , "3.12", "3.13", "3.14"]
steps:
- name: Checkout Repository
uses: actions/checkout@v7
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v7
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# CLAUDE.md — batcontrol

batcontrol charges a home battery when grid prices are cheap and preserves it for expensive
hours, based on dynamic tariffs, solar forecast, and consumption forecast. Python 3.9-3.13
(primary target: 3.11). Full contribution guidelines:
hours, based on dynamic tariffs, solar forecast, and consumption forecast. Python 3.11-3.14
(primary target: 3.14). Full contribution guidelines:
[`.github/copilot-instructions.md`](.github/copilot-instructions.md)

## Commands

```bash
./run_tests.sh # full suite + coverage (creates .venv via uv)
uv venv --python 3.13 --allow-existing # setup only
uv venv --python 3.14 --allow-existing # setup only
uv pip install -e '.[test]' pylint autopep8 # lint tools are not part of the test extras
uv run pytest tests/ -k <name> # single test / subset
uv run pylint src/batcontrol # target score >= 9.0 (10 if achievable)
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Stage 1: Build Stage
FROM python:3.13-alpine AS builder
FROM python:3.14-alpine AS builder

# Install uv for fast, reliable Python packaging
COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv
Expand All @@ -16,7 +16,7 @@ COPY ./src ./src
RUN uv build --wheel --out-dir /wheels

# Stage 2: Build the final image
FROM python:3.13-alpine
FROM python:3.14-alpine

ARG VERSION
ARG GIT_SHA
Expand Down
7 changes: 4 additions & 3 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,13 @@ Add the [batcontrol_ha_addon](https://github.com/MaStr/batcontrol_ha_addon) repo

## Local Python

Requires Python 3.13 and [uv](https://docs.astral.sh/uv/).
Requires Python 3.11 or newer (3.11 - 3.14 are supported and tested in CI; the
Docker image ships Python 3.14) and [uv](https://docs.astral.sh/uv/).

```sh
git clone https://github.com/MaStr/batcontrol.git
cd batcontrol
uv venv --python 3.13 --allow-existing
uv venv --python 3.14 --allow-existing
source .venv/bin/activate
uv pip install .
```
Expand Down Expand Up @@ -170,7 +171,7 @@ For working on the batcontrol source code, use an editable install:
```sh
git clone https://github.com/MaStr/batcontrol.git
cd batcontrol
uv venv --python 3.13 --allow-existing
uv venv --python 3.14 --allow-existing
source .venv/bin/activate
uv pip install --editable '.[test]'
```
Expand Down
19 changes: 13 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@ authors = [{name = "Stephan Mükusch", email = "muexxl@gmx.net"}]
license = "MIT"
license-files = ["LICENSE"]
readme = "README.md"
requires-python = ">3.8, <3.15"
requires-python = ">=3.11, <3.15"
dependencies = [
"numpy>=1.24",
"pandas>=2.0",
"PyYAML>=6.0",
# Python 3.14 needs higher floors: the older releases have no cp314 wheels,
# so a resolver would otherwise try to build them from source.
"numpy>=1.24; python_version < '3.14'",
"numpy>=2.3.2; python_version >= '3.14'",
"pandas>=2.0; python_version < '3.14'",
"pandas>=2.3.3; python_version >= '3.14'",
"PyYAML>=6.0; python_version < '3.14'",
"PyYAML>=6.0.3; python_version >= '3.14'",
"requests>=2.28",
"paho-mqtt>=2.0",
"packaging>=23.0",
"cachetools>=5.0",
"websockets>=11.0",
# websockets.asyncio.client is used by the HomeAssistant providers and was
# added in websockets 13.0.
"websockets>=13.0",
"schedule>=1.2.0",
"pytz>=2024.2"
]
Expand Down Expand Up @@ -55,7 +62,7 @@ test = [
]
docs = [
"mkdocs-material>=9.5,<10",
"mkdocs-llmstxt>=0.5.0,<1.0; python_version >= '3.10'",
"mkdocs-llmstxt>=0.5.0,<1.0",
]

# Bump Version
Expand Down
3 changes: 2 additions & 1 deletion run_tests.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/bin/bash

uv venv --python 3.13 --allow-existing
# Override with e.g. PYTHON_VERSION=3.11 ./run_tests.sh
uv venv --python "${PYTHON_VERSION:-3.14}" --allow-existing

# Install the package together with test dependencies from pyproject.toml
uv pip install -e '.[test]'
Expand Down
94 changes: 94 additions & 0 deletions src/batcontrol/async_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
""" Helpers for running asyncio coroutines from synchronous code.

batcontrol is a synchronous application, but a few providers talk to
HomeAssistant over a WebSocket and are therefore implemented as coroutines.
Those providers need a bridge from sync code into asyncio.

Historically that bridge was ``asyncio.get_event_loop()`` with a
``RuntimeError`` fallback that created a loop and never closed it. Up to
Python 3.13 ``get_event_loop()`` silently created a loop when none was set;
since Python 3.14 it raises ``RuntimeError`` instead, so the fallback ran on
every call and left a loop registered as the thread's current loop.

The helpers here delegate the loop lifecycle to ``asyncio.Runner``, which
cancels leftover tasks, flushes async generators and closes the loop on exit.
"""

import asyncio
import contextlib
import logging
from typing import Iterator

logger = logging.getLogger(__name__)
Comment on lines +19 to +22


def _assert_no_running_loop(context: str) -> None:
"""Raise RuntimeError if the calling thread already runs an event loop.

Args:
context: Name used in the error message, usually the caller.

Raises:
RuntimeError: If an event loop is already running in this thread.
"""
try:
asyncio.get_running_loop()
except RuntimeError:
# Expected case: no running loop, so we are free to create one.
return

raise RuntimeError(
f"{context} cannot be used from a running event loop; "
"await the coroutine directly instead"
)


@contextlib.contextmanager
def managed_event_loop() -> Iterator[asyncio.AbstractEventLoop]:
"""Provide a private event loop for several run_until_complete() calls.

Use this when a single connection has to be reused across multiple
coroutine invocations. The loop is closed when the block exits, also on
error.

Yields:
A fresh event loop, set as the current loop for this thread.

Raises:
RuntimeError: If an event loop is already running in this thread.
"""
_assert_no_running_loop("managed_event_loop()")

with asyncio.Runner() as runner:
loop = runner.get_loop()
# Register the loop so libraries that call get_event_loop() outside of
# a running loop still find it. Runner.close() resets this.
asyncio.set_event_loop(loop)
try:
yield loop
finally:
asyncio.set_event_loop(None)


def run_coroutine(coro):
"""Run a single coroutine from synchronous code and return its result.

Args:
coro: The coroutine to run.

Returns:
Whatever the coroutine returns.

Raises:
RuntimeError: If an event loop is already running in this thread. The
coroutine is closed in that case, so it does not trigger a
"coroutine was never awaited" warning.
"""
try:
_assert_no_running_loop("run_coroutine()")
except RuntimeError:
coro.close()
raise

with managed_event_loop() as loop:
return loop.run_until_complete(coro)
Loading
Loading