diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 16e9a78..389532a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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: diff --git a/devices.json b/devices.json index 9d891b7..4c0883e 100644 --- a/devices.json +++ b/devices.json @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/images/air-1.jpg b/images/air-1.jpg new file mode 100644 index 0000000..06b6427 Binary files /dev/null and b/images/air-1.jpg differ diff --git a/images/air-1.png b/images/air-1.png deleted file mode 100644 index 5679bf9..0000000 Binary files a/images/air-1.png and /dev/null differ diff --git a/images/btn-1.jpg b/images/btn-1.jpg index 003e8dd..91c5caf 100644 Binary files a/images/btn-1.jpg and b/images/btn-1.jpg differ diff --git a/images/cast-1.jpg b/images/cast-1.jpg index b1848a0..b497015 100644 Binary files a/images/cast-1.jpg and b/images/cast-1.jpg differ diff --git a/images/msr-2.jpg b/images/msr-2.jpg new file mode 100644 index 0000000..a7e8fc7 Binary files /dev/null and b/images/msr-2.jpg differ diff --git a/images/msr-2.png b/images/msr-2.png deleted file mode 100644 index 7ad02db..0000000 Binary files a/images/msr-2.png and /dev/null differ diff --git a/images/mtr-1.jpg b/images/mtr-1.jpg new file mode 100644 index 0000000..de99ff7 Binary files /dev/null and b/images/mtr-1.jpg differ diff --git a/images/mtr-1.png b/images/mtr-1.png deleted file mode 100644 index 7d75069..0000000 Binary files a/images/mtr-1.png and /dev/null differ diff --git a/images/plt-1.jpg b/images/plt-1.jpg new file mode 100644 index 0000000..711a71c Binary files /dev/null and b/images/plt-1.jpg differ diff --git a/images/plt-1.png b/images/plt-1.png deleted file mode 100644 index 6be626c..0000000 Binary files a/images/plt-1.png and /dev/null differ diff --git a/images/pump-1.jpg b/images/pump-1.jpg index ab89f19..378c7d6 100644 Binary files a/images/pump-1.jpg and b/images/pump-1.jpg differ diff --git a/images/r-pro-1.jpg b/images/r-pro-1.jpg new file mode 100644 index 0000000..d1b1e1c Binary files /dev/null and b/images/r-pro-1.jpg differ diff --git a/images/r-pro-1.png b/images/r-pro-1.png deleted file mode 100644 index 94326a0..0000000 Binary files a/images/r-pro-1.png and /dev/null differ diff --git a/images/temp-1.jpg b/images/temp-1.jpg new file mode 100644 index 0000000..e9deada Binary files /dev/null and b/images/temp-1.jpg differ diff --git a/images/temp-1.png b/images/temp-1.png deleted file mode 100644 index 7459201..0000000 Binary files a/images/temp-1.png and /dev/null differ diff --git a/scripts/fetch_images.py b/scripts/fetch_images.py index 2922f12..90039ab 100644 --- a/scripts/fetch_images.py +++ b/scripts/fetch_images.py @@ -2,7 +2,14 @@ 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 @@ -10,6 +17,36 @@ 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/.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: @@ -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: diff --git a/scripts/test_fetch_images.py b/scripts/test_fetch_images.py new file mode 100644 index 0000000..5db8d5a --- /dev/null +++ b/scripts/test_fetch_images.py @@ -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()