1
0

pattern_model.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. """Pattern list model backed by the firmware's ``/sand_patterns`` route.
  2. Patterns now live on the table's SD card, not the local filesystem. This model
  3. fetches the catalogue over HTTP and renders each ``.thr`` preview locally
  4. (cached to disk), updating rows as previews become available.
  5. """
  6. import asyncio
  7. import logging
  8. from PySide6.QtCore import QAbstractListModel, Qt, Slot, QModelIndex
  9. from PySide6.QtQml import QmlElement
  10. from firmware_client import FirmwareClient
  11. import thr_preview
  12. QML_IMPORT_NAME = "DuneWeaver"
  13. QML_IMPORT_MAJOR_VERSION = 1
  14. logger = logging.getLogger("DuneWeaver.PatternModel")
  15. @QmlElement
  16. class PatternModel(QAbstractListModel):
  17. """Model for the pattern grid, sourced from the sand table over HTTP."""
  18. NameRole = Qt.UserRole + 1
  19. PathRole = Qt.UserRole + 2
  20. PreviewRole = Qt.UserRole + 3
  21. def __init__(self):
  22. super().__init__()
  23. self._patterns = [] # all patterns [{name, path}]
  24. self._filtered_patterns = [] # current view
  25. self._search_text = ""
  26. self._previews = {} # rel_path -> cached png path ("" = none)
  27. self._rendering = set() # rel_paths with an in-flight render
  28. self._render_attempts = {} # rel_path -> transient-failure count
  29. self._warm_task = None # background cache-warmer task
  30. self._client = FirmwareClient.instance()
  31. self._client.baseUrlChanged.connect(self._on_table_changed)
  32. self.refresh()
  33. def roleNames(self):
  34. return {
  35. self.NameRole: b"name",
  36. self.PathRole: b"path",
  37. self.PreviewRole: b"preview",
  38. }
  39. def rowCount(self, parent=QModelIndex()):
  40. return len(self._filtered_patterns)
  41. def data(self, index, role):
  42. if not index.isValid() or index.row() >= len(self._filtered_patterns):
  43. return None
  44. pattern = self._filtered_patterns[index.row()]
  45. if role == self.NameRole:
  46. return pattern["name"]
  47. elif role == self.PathRole:
  48. return pattern["path"]
  49. elif role == self.PreviewRole:
  50. return self._preview_for(pattern["name"])
  51. return None
  52. # ------------------------------------------------------------- previews
  53. def _preview_for(self, rel_path):
  54. """Return a cached preview path, kicking off a render if needed.
  55. Runs on the GUI thread for every row the grid materializes, so it
  56. must not touch the disk: the on-disk cache is folded into
  57. ``self._previews`` in one scan per refresh (see ``_fetch_patterns``).
  58. """
  59. cached = self._previews.get(rel_path)
  60. if cached is not None:
  61. return cached
  62. # Not cached yet - render asynchronously and update the row later.
  63. self._schedule_render(rel_path)
  64. return ""
  65. def _schedule_render(self, rel_path):
  66. if rel_path in self._rendering or not self._client.base_url:
  67. return
  68. self._rendering.add(rel_path)
  69. try:
  70. asyncio.get_event_loop().create_task(self._render(rel_path))
  71. except RuntimeError:
  72. self._rendering.discard(rel_path)
  73. # Transient fetch failures (timeouts, board busy) are retried with a
  74. # delay; only a real result — a PNG path or a definitive "" (pattern has
  75. # nothing to render) — is cached. Caching "" on a timeout used to leave
  76. # tiles on "No Preview" forever.
  77. _MAX_RENDER_ATTEMPTS = 3
  78. _RETRY_DELAY_S = 10
  79. async def _render(self, rel_path):
  80. base_url = self._client.base_url
  81. try:
  82. path = await thr_preview.render_preview(self._client, base_url, rel_path)
  83. finally:
  84. self._rendering.discard(rel_path)
  85. if base_url != self._client.base_url:
  86. return # table changed under us; drop stale result
  87. if path is None:
  88. attempts = self._render_attempts.get(rel_path, 0) + 1
  89. self._render_attempts[rel_path] = attempts
  90. if attempts < self._MAX_RENDER_ATTEMPTS:
  91. await asyncio.sleep(self._RETRY_DELAY_S)
  92. if base_url == self._client.base_url:
  93. self._schedule_render(rel_path)
  94. # else: leave uncached — scrolling back to the tile retries fresh
  95. else:
  96. self._render_attempts.pop(rel_path, None)
  97. return
  98. self._render_attempts.pop(rel_path, None)
  99. self._previews[rel_path] = path
  100. self._emit_preview_changed(rel_path)
  101. def _emit_preview_changed(self, rel_path):
  102. for row, pattern in enumerate(self._filtered_patterns):
  103. if pattern["name"] == rel_path:
  104. idx = self.index(row, 0)
  105. self.dataChanged.emit(idx, idx, [self.PreviewRole])
  106. break
  107. # -------------------------------------------------------------- fetching
  108. def _on_table_changed(self, _base_url):
  109. if self._warm_task is not None:
  110. self._warm_task.cancel()
  111. self._warm_task = None
  112. self._previews.clear()
  113. self._rendering.clear()
  114. self._render_attempts.clear()
  115. self.refresh()
  116. @Slot()
  117. def refresh(self):
  118. try:
  119. asyncio.get_event_loop().create_task(self._fetch_patterns())
  120. except RuntimeError:
  121. logger.debug("No running loop yet; patterns will load once started")
  122. async def _fetch_patterns(self):
  123. if not self._client.base_url:
  124. self._apply_patterns([])
  125. return
  126. try:
  127. paths = await self._client.patterns()
  128. except Exception as exc:
  129. logger.warning(f"Failed to fetch patterns: {exc}")
  130. return
  131. patterns = []
  132. for p in paths:
  133. rel = str(p).lstrip("/")
  134. # /sand_patterns may return paths with or without a /patterns prefix
  135. if rel.startswith("patterns/"):
  136. rel = rel[len("patterns/"):]
  137. patterns.append({"name": rel, "path": rel})
  138. patterns.sort(key=lambda x: x["name"].lower())
  139. # Fold the on-disk preview cache into _previews with a single
  140. # directory scan, off the GUI thread — data() must never hit the disk.
  141. base_url = self._client.base_url
  142. index = await asyncio.to_thread(thr_preview.preview_index, base_url)
  143. if base_url != self._client.base_url:
  144. return # table changed under us; drop stale result
  145. for pattern in patterns:
  146. rel = pattern["name"]
  147. if rel not in self._previews:
  148. on_disk = index.get(thr_preview.safe_name(rel))
  149. if on_disk:
  150. self._previews[rel] = on_disk
  151. self._apply_patterns(patterns)
  152. self._start_warmer()
  153. # ------------------------------------------------------------ cache warm
  154. def _start_warmer(self):
  155. """Render the still-missing previews in the background, one at a time.
  156. Without this, a fresh install shows placeholder dishes for the whole
  157. first pass over the library. Only patterns with a local .thr are
  158. warmed (no board I/O); board-only patterns stay lazy. Visible tiles
  159. still render on demand and win the CPU cap's other slot.
  160. """
  161. if self._warm_task is not None:
  162. self._warm_task.cancel()
  163. self._warm_task = None
  164. try:
  165. self._warm_task = asyncio.get_event_loop().create_task(self._warm_previews())
  166. except RuntimeError:
  167. pass
  168. async def _warm_previews(self):
  169. base_url = self._client.base_url
  170. warmed = 0
  171. for pattern in list(self._patterns):
  172. if base_url != self._client.base_url:
  173. return # table changed; the new fetch starts a fresh warmer
  174. rel = pattern["name"]
  175. if rel in self._previews or rel in self._rendering:
  176. continue
  177. if not await asyncio.to_thread(thr_preview.has_local_source, rel):
  178. continue
  179. self._rendering.add(rel)
  180. await self._render(rel)
  181. warmed += 1
  182. # Breathe between renders so the warmer never monopolizes the pool.
  183. await asyncio.sleep(0.1)
  184. if warmed:
  185. logger.info(f"Preview cache warmed: {warmed} patterns rendered")
  186. def _apply_patterns(self, patterns):
  187. self.beginResetModel()
  188. self._patterns = patterns
  189. self._filtered_patterns = self._apply_filter(patterns, self._search_text)
  190. self.endResetModel()
  191. logger.info(f"Loaded {len(self._patterns)} patterns")
  192. # ---------------------------------------------------------------- filter
  193. @staticmethod
  194. def _apply_filter(patterns, search_text):
  195. if not search_text:
  196. return list(patterns)
  197. needle = search_text.lower()
  198. return [p for p in patterns if needle in p["name"].lower()]
  199. @Slot(str)
  200. def filter(self, search_text):
  201. self._search_text = search_text or ""
  202. self.beginResetModel()
  203. self._filtered_patterns = self._apply_filter(self._patterns, self._search_text)
  204. self.endResetModel()