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
2 changes: 2 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
python-version: "3.12"
- name: Validator unit tests (offline)
run: python scripts/test_validate_registry.py
- name: Image script unit tests (offline)
run: python scripts/test_fetch_images.py
- run: python scripts/validate_registry.py

ui-tests:
Expand Down
12 changes: 6 additions & 6 deletions devices.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"name": "MSR-2",
"category": "mmWave presence",
"description": "mmWave multisensor (LD2410B)",
"image": "images/msr-2.png",
"image": "images/msr-2.jpg",
"imageSource": "https://cdn.shopify.com/s/files/1/0792/0959/5187/files/2025_MSR-2_W_E_and_H.png?v=1763054411",
"wiki": "https://wiki.apolloautomation.com/products/msr2/introduction/",
"repo": "ApolloAutomation/MSR-2",
Expand All @@ -37,7 +37,7 @@
"name": "MTR-1",
"category": "mmWave presence",
"description": "Multi-target radar multisensor (LD2450)",
"image": "images/mtr-1.png",
"image": "images/mtr-1.jpg",
"imageSource": "https://cdn.shopify.com/s/files/1/0792/0959/5187/files/2025_MTR-1_W_E_adn_H.png?v=1763054477",
"wiki": "https://wiki.apolloautomation.com/products/mtr1/introduction/",
"repo": "ApolloAutomation/MTR-1",
Expand All @@ -53,7 +53,7 @@
"name": "R_PRO-1",
"category": "mmWave presence",
"description": "PoE dual mmWave multisensor (LD2450, optional LD2412)",
"image": "images/r-pro-1.png",
"image": "images/r-pro-1.jpg",
"imageSource": "https://cdn.shopify.com/s/files/1/0792/0959/5187/files/R_PRO-1_WWHA_EH_and_H.png?v=1771963575",
"wiki": "https://wiki.apolloautomation.com/products/rpro1/introduction/",
"repo": "ApolloAutomation/R_PRO-1",
Expand All @@ -70,7 +70,7 @@
"name": "AIR-1",
"category": "Air quality",
"description": "Air quality sensor",
"image": "images/air-1.png",
"image": "images/air-1.jpg",
"imageSource": "https://cdn.shopify.com/s/files/1/0792/0959/5187/files/2025_AIR-1_W_E_and_H_12ee80a0-1778-4f86-8db3-e0033a328b44.png?v=1763054155",
"wiki": "https://wiki.apolloautomation.com/products/air1/introduction/",
"repo": "ApolloAutomation/AIR-1",
Expand All @@ -86,7 +86,7 @@
"name": "TEMP-1",
"category": "Temperature",
"description": "Temperature sensor",
"image": "images/temp-1.png",
"image": "images/temp-1.jpg",
"imageSource": "https://cdn.shopify.com/s/files/1/0792/0959/5187/files/TEMP-1B_WWHA_EH_and_H.png?v=1771963269",
"wiki": "https://wiki.apolloautomation.com/products/temp1/introduction/",
"repo": "ApolloAutomation/TEMP-1",
Expand All @@ -102,7 +102,7 @@
"name": "PLT-1",
"category": "Plant",
"description": "Plant sensor",
"image": "images/plt-1.png",
"image": "images/plt-1.jpg",
"imageSource": "https://cdn.shopify.com/s/files/1/0792/0959/5187/files/PLT-1_WWHA_EH_and_H.png?v=1771963184",
"wiki": "https://wiki.apolloautomation.com/products/plt1/introduction/",
"repo": "ApolloAutomation/PLT-1",
Expand Down
Binary file added images/air-1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed images/air-1.png
Binary file not shown.
Binary file modified images/btn-1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/cast-1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/msr-2.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed images/msr-2.png
Binary file not shown.
Binary file added images/mtr-1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed images/mtr-1.png
Binary file not shown.
Binary file added images/plt-1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed images/plt-1.png
Binary file not shown.
Binary file modified images/pump-1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/r-pro-1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed images/r-pro-1.png
Binary file not shown.
Binary file added images/temp-1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed images/temp-1.png
Binary file not shown.
50 changes: 46 additions & 4 deletions scripts/fetch_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,51 @@

Run from repo root: python scripts/fetch_images.py
Re-run any time to refresh; commits stay the source of truth.

Shopify CDN sources are requested as small progressive-JPEG renditions
(width + format=pjpg), which shrinks the ~700-900KB product PNGs to tens of KB.
The CDN ignores format=webp on this store, so pjpg is the only conversion that
takes effect. When a device's image extension changes (e.g. .png -> .jpg) the
old file is removed so it doesn't linger in the repo and the deploy.
"""
import glob
import json
import os
import urllib.parse
import urllib.request

REG = "devices.json"
UA = {"User-Agent": "apollo-installer-image-fetch"}
WIDTH = 600 # ~3x the largest rendered size (hub card), plenty for retina
IMG_EXTS = (".png", ".jpg", ".jpeg", ".webp") # only ever remove image files

def rendition_url(src):
"""Return (url, ext) to fetch for an imageSource.

Shopify CDN sources are downscaled and converted to progressive JPEG, so the
on-disk file is always .jpg. Other sources are fetched unchanged, keeping the
extension from their path (defaulting to .png).
"""
if "cdn.shopify.com" not in src:
path = urllib.parse.urlparse(src).path
ext = os.path.splitext(path)[1].lower() or ".png"
return src, ext
sep = "&" if "?" in src else "?"
return f"{src}{sep}width={WIDTH}&format=pjpg", ".jpg"

def remove_stale(dest):
"""Delete any same-id image whose extension differs from the one just saved.

Only known image extensions are ever removed, so a same-named non-image file
(e.g. a stray images/<id>.txt) is never touched.
"""
base = os.path.splitext(dest)[0] # e.g. images/air-1
for old in glob.glob(base + ".*"):
if os.path.splitext(old)[1].lower() not in IMG_EXTS:
continue
if os.path.normpath(old) != os.path.normpath(dest):
os.remove(old)
print(f" removed stale {old}")

def main():
with open(REG, encoding="utf-8") as f:
Expand All @@ -20,14 +57,19 @@ def main():
if not src:
print(f"{dev['id']}: no imageSource, skipped")
continue
path = urllib.parse.urlparse(src).path
ext = os.path.splitext(path)[1].lower() or ".png"
sep = "&" if "?" in src else "?"
url = f"{src}{sep}width=800" if "cdn.shopify.com" in src else src
url, ext = rendition_url(src)
dest = f"images/{dev['id']}{ext}"
# Non-Shopify sources can't be CDN-downscaled; if we already have the
# file (it may be hand-resized, like msr-1), keep it rather than
# re-downloading the full-size original and undoing that work.
if "cdn.shopify.com" not in src and os.path.exists(dest):
dev["image"] = dest
print(f"{dev['id']}: kept existing {dest} (non-Shopify source, not re-fetched)")
continue
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=60) as r, open(dest, "wb") as out:
out.write(r.read())
remove_stale(dest)
dev["image"] = dest
print(f"{dev['id']}: {dest} ({os.path.getsize(dest)} bytes)")
with open(REG, "w", encoding="utf-8") as f:
Expand Down
81 changes: 81 additions & 0 deletions scripts/test_fetch_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Offline unit tests for fetch_images.py (stdlib unittest, no deps).

Run: python scripts/test_fetch_images.py
Covers the CDN rendition URL building and stale-file cleanup without touching
the network.
"""
import os
import sys
import tempfile
import unittest

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fetch_images as fi # noqa: E402

SHOPIFY = "https://cdn.shopify.com/s/files/1/x/files/A.png?v=123"


class RenditionUrl(unittest.TestCase):
def test_shopify_png_requests_pjpg_and_saves_jpg(self):
url, ext = fi.rendition_url(SHOPIFY)
self.assertIn("format=pjpg", url)
self.assertIn(f"width={fi.WIDTH}", url)
# Existing query string means the params must be appended with '&'.
self.assertIn("?v=123&", url)
# format=pjpg returns image/jpeg, so the on-disk file is always .jpg.
self.assertEqual(ext, ".jpg")

def test_shopify_without_query_uses_question_mark(self):
url, ext = fi.rendition_url("https://cdn.shopify.com/s/files/1/x/files/A.png")
self.assertIn("?width=", url)
self.assertNotIn("??", url)
self.assertEqual(ext, ".jpg")

def test_shopify_jpg_source_still_pjpg(self):
url, ext = fi.rendition_url("https://cdn.shopify.com/s/files/1/x/files/B.jpg?v=9")
self.assertIn("format=pjpg", url)
self.assertEqual(ext, ".jpg")

def test_non_shopify_source_unchanged(self):
src = "https://wiki.apolloautomation.com/assets/productimage.jpg"
url, ext = fi.rendition_url(src)
self.assertEqual(url, src)
self.assertEqual(ext, ".jpg")

def test_non_shopify_defaults_to_png_when_no_ext(self):
url, ext = fi.rendition_url("https://example.com/image")
self.assertEqual(ext, ".png")


class RemoveStale(unittest.TestCase):
def test_removes_old_extension_keeps_current(self):
with tempfile.TemporaryDirectory() as d:
keep = os.path.join(d, "air-1.jpg")
stale = os.path.join(d, "air-1.png")
open(keep, "w").close()
open(stale, "w").close()
fi.remove_stale(keep)
self.assertTrue(os.path.exists(keep))
self.assertFalse(os.path.exists(stale))

def test_does_not_touch_other_devices(self):
with tempfile.TemporaryDirectory() as d:
keep = os.path.join(d, "air-1.jpg")
other = os.path.join(d, "air-10.jpg") # different device id
open(keep, "w").close()
open(other, "w").close()
fi.remove_stale(keep)
self.assertTrue(os.path.exists(other))

def test_does_not_touch_same_base_non_image(self):
with tempfile.TemporaryDirectory() as d:
keep = os.path.join(d, "air-1.jpg")
note = os.path.join(d, "air-1.txt") # same base, not an image
open(keep, "w").close()
open(note, "w").close()
fi.remove_stale(keep)
self.assertTrue(os.path.exists(note))


if __name__ == "__main__":
unittest.main()
Loading