4. Migrate the Docker build to uv, and drop arm/v7 support¶
- Status: accepted
- Date: 2026-08-12
Context¶
The Docker build installs dependencies with pip from a generated
requirements.txt (generate_requirements.py flattens pyproject.toml's
dependencies + forecast extra into it at CI time). This has two problems:
- No transitive pinning.
requirements.txtonly pins the top-level packagespyproject.tomlnames — everything transitive (pydantic-core,aiohappyeyeballs, scikit-learn's own dependency tree, and so on) resolves fresh on every build. The image is not actually reproducible, despite the Docker layer cache making it look stable build to build. - A second source of truth. The project already maintains
uv.lock(full transitive pins, used by dev tooling) alongside the hand-parsedrequirements.txtthe Docker image actually installs from.generate_requirements.pyexists only to bridge that gap — it re-derives a subset of whatuv.lockalready knows, less precisely.
The fix is to point the Docker build at uv.lock directly — uv sync
--frozen — and delete generate_requirements.py/requirements.txt entirely.
That was blocked by arm/v7. uv ships official Docker images
(ghcr.io/astral-sh/uv) for amd64/arm64 only, and uv.lock resolves
against plain PyPI wheels project-wide — there is no piwheels fallback path
for the one package (aiocsv) that has only ever had an arm/v7 wheel on
piwheels, not PyPI. A uv-native Docker build and continued arm/v7 support
are mutually exclusive as things stand. So the uv migration is the actual
goal of this decision; dropping arm/v7 is what makes it possible.
Why dropping arm/v7 is also the right call on its own terms¶
arm/v7 (32-bit ARM, armhf) has been a second-class platform for a while
already: the forecast service was excluded from it back in
2f66d73
(2025-07-29) because pvlearn's dependencies (scikit-learn, scipy) don't ship
usable arm/v7 wheels, and README.md carried a note about it ever since.
- Home Assistant dropped 32-bit entirely with the 2025.12 release (announced 2025-05-22, citing under 1% adoption). This project's companion is a Home Assistant integration; a user still on 32-bit cannot run a current Home Assistant next to it either way.
arm/v7hardware mostly isn't 32-bit anymore. Raspberry Pi 3 and up are ARMv8 boards; Raspberry Pi OS has installed the 64-bit (arm64) userland by default since Bookworm. The realistic remaining audience is Pi 2 boards and old images on newer Pi hardware that were never reinstalled — not the hardware itself running out of options.- The dependency problem has gotten worse, not better. Checked while
updating pvlearn to 0.4.0: resolving
arm/v7wheel-only against PyPI alone fails on a single package now —aiocsv. Thepiwheelsextra index this repo has carried for years exists at this point to serve that one package. - No usage signal exists to weigh against this. Docker Hub's API returns
no pull counts per tag, and
last_pulledtimestamps update on every registry mirror/scanner hit regardless of real usage — even2.3.1-armv7from February shows a pull from last night. There is no way to tell realarm/v7deployments from index noise, so a deprecation cycle would not produce better information than deciding now. - It is a third of the build matrix and the slowest third.
build-dockerranamd64/arm64/arm/v7under QEMU emulation onamd64GitHub runners;arm/v7was consistently among the slower legs.
Decision¶
Hard drop arm/v7. No deprecation release, no warning period. Releases
publish amd64 and arm64 images only; the multi-arch manifest (latest,
:version) stops including an armv7 variant. Existing *-armv7 tags on
Docker Hub and GHCR are not deleted — they remain pullable as a last
known-good snapshot, they simply stop receiving new versions.
Migrate the Docker build to uv. The build stage installs from uv.lock
directly via uv sync --frozen --no-install-project --extra forecast,
replacing pip install -r requirements.txt. generate_requirements.py and
requirements.txt generation are deleted, not merely simplified.
The alternative to the hard drop — a deprecation cycle with a warning release
— was rejected because there is no telemetry to time it against (see above),
and because the project has already signalled this for a year: the
forecast-service note has been in README.md since July 2025.
Users still on arm/v7 who need a newer version than the last published
*-armv7 tag can build the image themselves. The rest of this document is
that recipe: the exact Dockerfile, requirements split and CI logic this
project used for arm/v7, as of the last commit that had it —
f11e56d on the
storedge branch — preserved here so it survives independently of git
history or of anyone still remembering how the pieces fit together.
Self-build recipe for arm/v7¶
This assumes building natively on the target device (a Raspberry Pi
running a 32-bit OS, or equivalent). That sidesteps the one part of the CI
setup that doesn't matter for a single local build: QEMU emulation and
multi-platform buildx. docker build on the device itself already produces
a native arm/v7 image.
1. Get the source¶
git clone https://github.com/DerOetzi/solaredge2mqtt.git
cd solaredge2mqtt
git checkout <the tag or commit you want to build>
Newer commits have dropped requirements-armv7.txt generation, the
Dockerfile's TARGETARCH branch, and eventually requirements.txt/
generate_requirements.py altogether (that is what this ADR documents the
removal of). If building past that point, recreate the files below from this
document before proceeding, or start from a commit that still has them.
2. generate_requirements.py, as it stood before removal¶
Regenerates requirements.txt (all dependencies plus the forecast extra)
and requirements-armv7.txt (base dependencies only — no forecast, since
pvlearn's ML dependencies don't build on this platform) from
pyproject.toml:
#!/usr/bin/env python3
from pathlib import Path
import tomli
def main() -> None:
pyproject = Path("pyproject.toml")
with pyproject.open("rb") as f:
data = tomli.load(f)
project = data.get("project", {})
dependencies = project.get("dependencies", [])
optional = project.get("optional-dependencies", {})
# requirements.txt: alle deps + forecast
requirements_all = sorted(set(dependencies + optional.get("forecast", [])))
# requirements-armv7.txt: nur deps
requirements_arm = sorted(set(dependencies))
Path("requirements.txt").write_text("\n".join(requirements_all) + "\n")
Path("requirements-armv7.txt").write_text("\n".join(requirements_arm) + "\n")
print("✅ requirements.txt und requirements-armv7.txt wurden generiert.")
if __name__ == "__main__":
main()
Run it once before building:
This produces requirements-armv7.txt — the base runtime dependencies,
without the forecast/ML stack. That absence is not a bug to work around:
scikit-learn and scipy do not have usable arm/v7 wheels, and building them
from source under emulation is what made this platform expensive to support
in the first place. A self-built arm/v7 image has no forecast service, same
as every arm/v7 image this project ever published.
3. The Dockerfile, as it stood before removal¶
FROM python:3.13-slim AS buildimage
ARG TARGETARCH
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/venv/bin:$PATH"
COPY requirements*.txt .
RUN set -eux && \
apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
gcc && \
rm -rf /var/lib/apt/lists/* && \
python3 -m venv /venv && \
. /venv/bin/activate && \
pip install --upgrade pip && \
if [ "$TARGETARCH" = "arm" ]; then \
pip install \
--prefer-binary \
--no-cache-dir \
--extra-index-url https://www.piwheels.org/simple \
--extra-index-url https://download.pytorch.org/whl/cpu \
-r requirements-armv7.txt; \
else \
pip install \
--prefer-binary \
--no-cache-dir \
--extra-index-url https://download.pytorch.org/whl/cpu \
-r requirements.txt; \
fi
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/venv/bin:$PATH"
WORKDIR /app
RUN set -eux && \
apt-get update && \
apt-get install -y --no-install-recommends gosu && \
rm -rf /var/lib/apt/lists/* && \
adduser --uid 1000 --disabled-password --gecos '' solaredge2mqtt && \
mkdir -p /app/config /app/cache && \
chown solaredge2mqtt:solaredge2mqtt /app/config /app/cache && \
chmod 755 /app/config && \
chmod 700 /app/cache
COPY --chown=root:solaredge2mqtt --chmod=755 --from=buildimage /venv /venv
COPY --chown=root:solaredge2mqtt --chmod=755 \
solaredge2mqtt/ ./solaredge2mqtt/
COPY --chown=root:solaredge2mqtt --chmod=755 \
pyproject.toml README.md LICENSE ./
COPY --chown=root:root --chmod=755 docker-entrypoint.sh /usr/local/bin/
VOLUME ["/app/config"]
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["python3", "-m", "solaredge2mqtt"]
TARGETARCH is normally supplied by buildx during a multi-platform build.
Building natively with plain docker build, set it explicitly instead —
that's what selects the requirements-armv7.txt / piwheels branch:
4. Run it¶
Same as any other image — see Docker deployment:
mkdir -p config
docker run -d --name solaredge2mqtt \
-v $(pwd)/config:/app/config \
-e "TZ=Europe/Berlin" \
--restart unless-stopped \
solaredge2mqtt:armv7-self
5. What CI did that a local build doesn't need¶
For completeness, .github/workflows/build_project.yml's build-docker job
built arm/v7 as one leg of a matrix (arch: [amd64, arm64, arm/v7]) on an
amd64 GitHub runner, using QEMU emulation and buildx for the cross-build:
- name: Set up QEMU
uses: docker/setup-qemu-action@...
with:
platforms: linux/amd64,linux/arm64,linux/arm/v7
- name: Build and push Docker image
uses: docker/build-push-action@...
with:
platforms: linux/${{ matrix.arch }}
# ...
and merge-manifest stitched the three single-arch images
(*-amd64, *-arm64, *-armv7) into one multi-arch manifest with
docker buildx imagetools create. None of that is needed for a one-off local
build on real arm/v7 hardware — TARGETARCH=arm on a plain docker build
reproduces the same image content without emulation or a matrix.
Consequence¶
The Dockerfile's build stage is rewritten around uv: # syntax=docker/
dockerfile:1 at the top, the uv binary copied in from
ghcr.io/astral-sh/uv:0.12.3 (amd64/arm64 only — exactly what's left),
COPY pyproject.toml uv.lock as an early, cache-friendly layer, and RUN
--mount=type=cache,target=/root/.cache/uv,sharing=locked uv sync --frozen
--no-install-project --extra forecast in place of the old pip install.
build-essential/gcc are dropped entirely — every package in the resolved
dependency closure ships a manylinux wheel for both x86_64 and aarch64
on cp313, so nothing ever needs to compile. The dead --extra-index-url
https://download.pytorch.org/whl/cpu (present since the arm/v7 split was
introduced, never actually needed by anything in the dependency tree) is
dropped too. Stage 2, the final runtime image, is untouched — it never
depended on requirements.txt or pip, only on the /venv produced by stage
1.
On caching: the --mount=type=cache on uv sync speeds up local iterative
docker build runs, but GitHub Actions' type=gha layer cache (already
configured on build-docker, kept as-is) is what actually delivers "skip
reinstall when unrelated source files change" in CI — the same mechanism
requirements.txt relied on before, just keyed off uv.lock now via layer
ordering (dependencies installed before source is copied in). BuildKit
cache-mounts and GHA's layer cache are two different mechanisms; the former
isn't exported by type=gha without extra tooling
(buildkit-cache-dance), which isn't worth adding for a dependency closure
this size.
generate_requirements.py and tests/test_generate_requirements.py are
deleted, not simplified further — there is nothing left for them to do.
.gitignore's requirements.txt/requirements-armv7.txt lines and
MANIFEST.in's include requirements.txt are removed as dead weight.
build_project.yml's build-docker job drops its "install tomli" and
"generate requirements" steps (keeping the setuptools-scm install, still
needed by freeze_version.py). build-check/build-compat's pip cache key
— previously hashFiles('**/requirements.txt'), always empty since that file
never existed in those jobs — is fixed to hashFiles('uv.lock'), a real file
that changes exactly when dependencies do.
The result is a Docker image that is, for the first time, actually reproducible: every transitive dependency comes from the same lock file the project's own dev tooling is pinned against, not a fresh resolve on every build.