arcsecond 3.17.0__tar.gz → 3.17.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. {arcsecond-3.17.0 → arcsecond-3.17.2}/PKG-INFO +1 -1
  2. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/utils.py +4 -2
  3. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/commands.py +58 -12
  4. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/registry.py +6 -1
  5. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/sources/opencv.py +11 -8
  6. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/store.py +29 -2
  7. {arcsecond-3.17.0 → arcsecond-3.17.2}/pyproject.toml +1 -1
  8. arcsecond-3.17.2/tests/test_hosting_utils_secret_key.py +10 -0
  9. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_imagesources_commands.py +73 -0
  10. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_imagesources_store.py +71 -0
  11. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_imagesources_webcam.py +16 -0
  12. {arcsecond-3.17.0 → arcsecond-3.17.2}/uv.lock +1 -1
  13. {arcsecond-3.17.0 → arcsecond-3.17.2}/.docker/Dockerfile_postgres +0 -0
  14. {arcsecond-3.17.0 → arcsecond-3.17.2}/.docker/Dockerfile_redis +0 -0
  15. {arcsecond-3.17.0 → arcsecond-3.17.2}/.flake8 +0 -0
  16. {arcsecond-3.17.0 → arcsecond-3.17.2}/.github/dependabot.yml +0 -0
  17. {arcsecond-3.17.0 → arcsecond-3.17.2}/.github/workflows/pythonpublish.yml +0 -0
  18. {arcsecond-3.17.0 → arcsecond-3.17.2}/.github/workflows/tests.yml +0 -0
  19. {arcsecond-3.17.0 → arcsecond-3.17.2}/.gitignore +0 -0
  20. {arcsecond-3.17.0 → arcsecond-3.17.2}/LICENSE +0 -0
  21. {arcsecond-3.17.0 → arcsecond-3.17.2}/Makefile +0 -0
  22. {arcsecond-3.17.0 → arcsecond-3.17.2}/README.md +0 -0
  23. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/__init__.py +0 -0
  24. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/__version__.py +0 -0
  25. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/alpaca/__init__.py +0 -0
  26. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/alpaca/commands.py +0 -0
  27. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/alpaca/dome_probe.py +0 -0
  28. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/api/__init__.py +0 -0
  29. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/api/config.py +0 -0
  30. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/api/constants.py +0 -0
  31. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/api/endpoint.py +0 -0
  32. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/api/main.py +0 -0
  33. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/api/resources.py +0 -0
  34. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cli.py +0 -0
  35. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/__init__.py +0 -0
  36. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/auth.py +0 -0
  37. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/resources.py +0 -0
  38. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/__init__.py +0 -0
  39. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/constants.py +0 -0
  40. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/context.py +0 -0
  41. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  42. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  43. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  44. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  45. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  46. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/errors.py +0 -0
  47. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/logger.py +0 -0
  48. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/uploader.py +0 -0
  49. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/utils.py +0 -0
  50. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploader/walker.py +0 -0
  51. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/cloud/uploads.py +0 -0
  52. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/errors.py +0 -0
  53. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/__init__.py +0 -0
  54. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/backups.py +0 -0
  55. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/checks.py +0 -0
  56. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/constants.py +0 -0
  57. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/database.py +0 -0
  58. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/docker/__init__.py +0 -0
  59. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/docker/constants.py +0 -0
  60. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/docker/containers.py +0 -0
  61. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/docker/docker-compose.yml +0 -0
  62. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/docker/images.py +0 -0
  63. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/docker/utils.py +0 -0
  64. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/keygen/__init__.py +0 -0
  65. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/keygen/client.py +0 -0
  66. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/keygen/utils.py +0 -0
  67. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/local.py +0 -0
  68. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/main.py +0 -0
  69. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/postgres/init-db.sh +0 -0
  70. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/setup.py +0 -0
  71. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/hosting/validation.py +0 -0
  72. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/__init__.py +0 -0
  73. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/detection.py +0 -0
  74. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/proxy.py +0 -0
  75. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/runtime.py +0 -0
  76. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/sources/__init__.py +0 -0
  77. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/sources/base.py +0 -0
  78. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/sources/filewatch.py +0 -0
  79. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/imagesources/sources/network.py +0 -0
  80. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/options.py +0 -0
  81. {arcsecond-3.17.0 → arcsecond-3.17.2}/arcsecond/targets.py +0 -0
  82. {arcsecond-3.17.0 → arcsecond-3.17.2}/examples/example_upload_files.py +0 -0
  83. {arcsecond-3.17.0 → arcsecond-3.17.2}/requirements.txt +0 -0
  84. {arcsecond-3.17.0 → arcsecond-3.17.2}/setup.cfg +0 -0
  85. {arcsecond-3.17.0 → arcsecond-3.17.2}/setup.py +0 -0
  86. {arcsecond-3.17.0 → arcsecond-3.17.2}/sonar-project.properties +0 -0
  87. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/__init__.py +0 -0
  88. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/api/__init__.py +0 -0
  89. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/api/test_api.py +0 -0
  90. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/api/test_api_endpoint.py +0 -0
  91. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/api/test_config.py +0 -0
  92. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/api/test_targets.py +0 -0
  93. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/cloud/__init__.py +0 -0
  94. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/cloud/uploader/__init__.py +0 -0
  95. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  96. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  97. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  98. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  99. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  100. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  101. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/conftest.py +0 -0
  102. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/fixtures/file1.fits +0 -0
  103. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_alpaca_probe.py +0 -0
  104. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_cli.py +0 -0
  105. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_hosting_backups_restore.py +0 -0
  106. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_hosting_database.py +0 -0
  107. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_hosting_local.py +0 -0
  108. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_imagesources_detection.py +0 -0
  109. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_imagesources_network.py +0 -0
  110. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_imagesources_proxy.py +0 -0
  111. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/test_targets_planning.py +0 -0
  112. {arcsecond-3.17.0 → arcsecond-3.17.2}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 3.17.0
3
+ Version: 3.17.2
4
4
  Summary: CLI for arcsecond.io
5
5
  Project-URL: Homepage, https://github.com/arcsecond-io/cli
6
6
  Project-URL: Issues, https://github.com/arcsecond-io/cli/issues
@@ -42,8 +42,10 @@ def _read_env_value(key, env_path=None):
42
42
 
43
43
 
44
44
  def _get_random_secret_key():
45
- # No '%' to avoid interpolation surprises
46
- chars = "abcdefghijklmnopqrstuvwxyz0123456789!@#$^&*(-_=+)"
45
+ # The value lands unquoted in .env, which Compose interpolates: '$name'
46
+ # would be swallowed (with a "variable is not set" warning) and '%' is
47
+ # unsafe in configparser-style readers. Neither belongs in the alphabet.
48
+ chars = "abcdefghijklmnopqrstuvwxyz0123456789!@#^&*(-_=+)"
47
49
  return "".join(secrets.choice(chars) for _ in range(50))
48
50
 
49
51
 
@@ -40,6 +40,7 @@ import sys
40
40
  import time
41
41
  import urllib.request
42
42
  from string import Template
43
+ from typing import Optional
43
44
  from urllib.parse import urlsplit
44
45
 
45
46
  import click
@@ -275,12 +276,25 @@ def _list_registered(kinds, noun: str, add_example: str):
275
276
 
276
277
  click.echo(click.style(f"Registered {noun}s ({len(cameras)}):\n", bold=True))
277
278
  _print_table(
278
- [(c.id, c.display_kind, c.target, c.label or "") for c in cameras],
279
- ("ID", "KIND", "CAMERA", "NAME"),
279
+ [
280
+ (c.id, c.display_kind, c.target, _describe_specs(c), c.label or "")
281
+ for c in cameras
282
+ ],
283
+ ("ID", "KIND", "CAMERA", "DETAILS", "NAME"),
280
284
  )
281
285
  click.echo(f"\nForget one with: {_forget_example(kinds)} <ID>")
282
286
 
283
287
 
288
+ def _describe_specs(camera: Camera) -> str:
289
+ """A camera's resolution and frame rate, as recorded when it was added."""
290
+ specs = camera.specs or {}
291
+ width, height, fps = specs.get("width"), specs.get("height"), specs.get("fps")
292
+ if not (width and height):
293
+ return ""
294
+ size = f"{width}×{height}"
295
+ return f"{size}, {fps:.0f} fps" if fps else size
296
+
297
+
284
298
  def _detect_example(kinds) -> str:
285
299
  return "arcsecond allsky detect" if ALLSKY in kinds else "arcsecond webcam detect"
286
300
 
@@ -506,9 +520,12 @@ def webcam_add_cmd(camera, label):
506
520
 
507
521
  if target.isdigit():
508
522
  index = int(target)
523
+ # One probe, used for both jobs: telling the operator if nothing is
524
+ # there, and recording the camera's resolution while it is open.
525
+ device = _probe_index(index)
509
526
  _add(
510
- Camera(id="", kind=USB, index=index, label=label),
511
- absent_note=lambda: _index_absent_note(index),
527
+ Camera(id="", kind=USB, index=index, label=label, specs=_specs(device)),
528
+ absent_note=lambda: None if device else _nothing_at_index(index),
512
529
  )
513
530
  return
514
531
 
@@ -534,24 +551,53 @@ def webcam_add_cmd(camera, label):
534
551
  _add(Camera(id="", kind=NET, url=target, label=label))
535
552
 
536
553
 
537
- def _index_absent_note(index: int):
538
- """A note if nothing is at that index — the camera is registered either way.
554
+ def _probe_index(index: int):
555
+ """The device at ``index`` right now, or None.
539
556
 
540
- A webcam that is unplugged right now is still a camera the operator owns,
541
- and refusing to register it would mean re-typing the command later. Saying
542
- nothing would let a typo sit unnoticed until the proxy failed to open it.
557
+ Returns None rather than raising when OpenCV is missing or the probe
558
+ fails: not knowing what is plugged in is a reason to say less, not a
559
+ reason to refuse to register a camera.
543
560
  """
544
561
  try:
545
562
  from .sources.opencv import detect_webcams
546
563
 
547
- found = {d.identity for d in detect_webcams()}
564
+ for device in detect_webcams():
565
+ if device.identity == ("usb", index):
566
+ return device
548
567
  except Exception:
549
568
  return None
550
- if ("usb", index) in found:
569
+ return None
570
+
571
+
572
+ def _specs(device) -> Optional[dict]:
573
+ """Resolution and frame rate, as measured while the device was open.
574
+
575
+ Recorded so that listing a camera can report them without opening it —
576
+ `/detect` must stay answerable when a camera is busy or unplugged.
577
+ """
578
+ if device is None:
551
579
  return None
580
+ extra = device.extra or {}
581
+ specs = {
582
+ "width": extra.get("width"),
583
+ "height": extra.get("height"),
584
+ "fps": extra.get("fps"),
585
+ }
586
+ kept = {k: v for k, v in specs.items() if v}
587
+ return kept or None
588
+
589
+
590
+ def _nothing_at_index(index: int) -> str:
591
+ """Said when a camera is registered while nothing is plugged in at its index.
592
+
593
+ It is registered either way: a webcam that is unplugged right now is still
594
+ a camera the operator owns, and refusing would mean re-typing the command
595
+ later. Saying nothing would let a typo sit unnoticed until the proxy failed
596
+ to open it.
597
+ """
552
598
  return (
553
599
  f"nothing is at device index {index} right now. It stays registered — "
554
- "plug it in and it will be served."
600
+ "plug it in, then re-run this command to record its resolution."
555
601
  )
556
602
 
557
603
 
@@ -34,7 +34,12 @@ def build_source(camera: Camera) -> FrameSource:
34
34
  ``store.expanded``.
35
35
  """
36
36
  if camera.kind == USB:
37
- return OpenCVWebcamSource(camera.index, source_id=camera.id, label=camera.label)
37
+ return OpenCVWebcamSource(
38
+ camera.index,
39
+ source_id=camera.id,
40
+ label=camera.label,
41
+ specs=camera.specs,
42
+ )
38
43
  if camera.kind == NET:
39
44
  return build_network_source(camera.id, camera.url, camera.label)
40
45
  if camera.kind == ALLSKY:
@@ -51,8 +51,10 @@ class OpenCVWebcamSource(FrameSource):
51
51
  index: int,
52
52
  source_id: Optional[str] = None,
53
53
  label: Optional[str] = None,
54
+ specs: Optional[dict] = None,
54
55
  ):
55
56
  self.index = index
57
+ self.specs = specs or {}
56
58
  # Registered cameras pass their own short id. The fallback only exists
57
59
  # for probing a device that nobody has registered yet.
58
60
  self.id = source_id or f"usb:{index}"
@@ -100,14 +102,15 @@ class OpenCVWebcamSource(FrameSource):
100
102
  self._cap = None
101
103
 
102
104
  def info(self) -> SourceInfo:
103
- # Width/height/fps are only known once opened, and listing must not
104
- # open anything — `arcsecond webcam detect` is what reports those.
105
- return SourceInfo(
106
- id=self.id,
107
- kind=self.kind,
108
- label=self.label,
109
- extra={"transport": "usb", "index": self.index},
110
- )
105
+ # Width, height and fps are only knowable by opening the device, and
106
+ # listing must not open anything — one camera being busy would hold the
107
+ # answer up for every other. They are measured once, when the camera is
108
+ # registered, and reported from the store here.
109
+ extra = {"transport": "usb", "index": self.index}
110
+ for key in ("width", "height", "fps"):
111
+ if self.specs.get(key):
112
+ extra[key] = self.specs[key]
113
+ return SourceInfo(id=self.id, kind=self.kind, label=self.label, extra=extra)
111
114
 
112
115
 
113
116
  def detect_webcams(max_index: int = _MAX_PROBE) -> list[DetectedDevice]:
@@ -91,6 +91,11 @@ class Camera:
91
91
  index: Optional[int] = None # usb
92
92
  url: Optional[str] = None # net
93
93
  path: Optional[str] = None # allsky
94
+ # What probing learned about a USB camera: width, height, fps. Recorded at
95
+ # registration, when the device is opened anyway, so that listing it later
96
+ # can report them without opening anything. Absent for a camera registered
97
+ # while unplugged, and for the kinds that have no such thing.
98
+ specs: Optional[dict] = None
94
99
 
95
100
  @property
96
101
  def identity(self) -> tuple:
@@ -134,6 +139,8 @@ class Camera:
134
139
  entry: dict = {"kind": self.kind}
135
140
  if self.kind == USB:
136
141
  entry["index"] = self.index
142
+ if self.specs:
143
+ entry["specs"] = self.specs
137
144
  elif self.kind == NET:
138
145
  entry["url"] = self.url
139
146
  else:
@@ -154,7 +161,14 @@ def camera_from_json(cam_id: str, entry: dict) -> Optional[Camera]:
154
161
  index = entry.get("index")
155
162
  if not isinstance(index, int):
156
163
  return None
157
- return Camera(id=cam_id, kind=USB, index=index, label=label)
164
+ specs = entry.get("specs")
165
+ return Camera(
166
+ id=cam_id,
167
+ kind=USB,
168
+ index=index,
169
+ label=label,
170
+ specs=specs if isinstance(specs, dict) else None,
171
+ )
158
172
  if kind == NET:
159
173
  url = entry.get("url")
160
174
  if not url:
@@ -345,8 +359,17 @@ def add(camera: Camera, path: Optional[Path] = None) -> tuple[Camera, bool]:
345
359
 
346
360
  for existing in stored.values():
347
361
  if existing.identity == camera.identity:
362
+ # Re-registering the same camera is how its label and its measured
363
+ # resolution get refreshed — a camera that was unplugged the first
364
+ # time, or that has been set to a different mode since.
365
+ changed = False
348
366
  if camera.label and camera.label != existing.label:
349
367
  existing.label = camera.label
368
+ changed = True
369
+ if camera.specs and camera.specs != existing.specs:
370
+ existing.specs = camera.specs
371
+ changed = True
372
+ if changed:
350
373
  data["cameras"][existing.id] = existing.to_json()
351
374
  save(data, path)
352
375
  return existing, False
@@ -403,7 +426,11 @@ def expanded(cameras: list[Camera], expand) -> list[Camera]:
403
426
  continue
404
427
  usable.append(
405
428
  Camera(
406
- id=camera.id, kind=NET, url=url, label=camera.label, path=camera.path
429
+ id=camera.id,
430
+ kind=NET,
431
+ url=url,
432
+ label=camera.label,
433
+ path=camera.path,
407
434
  )
408
435
  )
409
436
  return usable
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arcsecond"
7
- version = "3.17.0"
7
+ version = "3.17.2"
8
8
  authors = [
9
9
  { name = "Cedric Foellmi", email = "cedric@arcsecond.io" },
10
10
  ]
@@ -0,0 +1,10 @@
1
+ from arcsecond.hosting.utils import _get_random_secret_key
2
+
3
+
4
+ def test_secret_key_has_no_compose_interpolation_characters():
5
+ # Compose interpolates '$name' inside .env; '%' trips configparser readers.
6
+ for _ in range(200):
7
+ key = _get_random_secret_key()
8
+ assert len(key) == 50
9
+ assert "$" not in key
10
+ assert "%" not in key
@@ -641,3 +641,76 @@ def test_a_proxy_is_only_stopped_once_its_process_is_gone(monkeypatch):
641
641
  assert commands._has_exited(8765, 4711) is False
642
642
  alive["gone"] = True
643
643
  assert commands._has_exited(8765, 4711) is True
644
+
645
+
646
+ # ---------------------------------------------------------------------------
647
+ # Resolution and frame rate
648
+ # ---------------------------------------------------------------------------
649
+
650
+
651
+ def _device(index=0, width=1280, height=720, fps=30.0):
652
+ return DetectedDevice(
653
+ kind="usb",
654
+ identity=("usb", index),
655
+ label=f"USB webcam #{index}",
656
+ extra={"index": index, "width": width, "height": height, "fps": fps},
657
+ )
658
+
659
+
660
+ def test_add_records_what_it_measured_while_the_device_was_open(cli, monkeypatch):
661
+ monkeypatch.setattr(
662
+ "arcsecond.imagesources.sources.opencv.detect_webcams",
663
+ lambda *a, **k: [_device()],
664
+ )
665
+ _run(cli, webcam, ["add", "0"])
666
+ (camera,) = store.all_cameras()
667
+ assert camera.specs == {"width": 1280, "height": 720, "fps": 30.0}
668
+
669
+
670
+ def test_listing_shows_the_resolution_without_probing(cli, monkeypatch):
671
+ monkeypatch.setattr(
672
+ "arcsecond.imagesources.sources.opencv.detect_webcams",
673
+ lambda *a, **k: [_device()],
674
+ )
675
+ _run(cli, webcam, ["add", "0"])
676
+
677
+ def explode(*a, **k):
678
+ raise AssertionError("listing probed the hardware")
679
+
680
+ monkeypatch.setattr("arcsecond.imagesources.sources.opencv.detect_webcams", explode)
681
+ assert "1280×720, 30 fps" in _run(cli, webcam, []).output
682
+
683
+
684
+ def test_a_camera_added_while_unplugged_says_how_to_fill_them_in(cli, monkeypatch):
685
+ _no_webcams(monkeypatch)
686
+ result = _run(cli, webcam, ["add", "0"])
687
+ assert "re-run this command to record its resolution" in result.output
688
+ assert store.all_cameras()[0].specs is None
689
+
690
+
691
+ def test_re_adding_once_plugged_in_records_the_resolution(cli, monkeypatch):
692
+ _no_webcams(monkeypatch)
693
+ _run(cli, webcam, ["add", "0"])
694
+ monkeypatch.setattr(
695
+ "arcsecond.imagesources.sources.opencv.detect_webcams",
696
+ lambda *a, **k: [_device()],
697
+ )
698
+ result = _run(cli, webcam, ["add", "0"])
699
+ assert "Already registered" in result.output
700
+ assert store.all_cameras()[0].specs["width"] == 1280
701
+
702
+
703
+ def test_the_proxy_is_given_the_recorded_resolution(cli, monkeypatch):
704
+ """It is what /detect reports, and what the backend shows the operator."""
705
+ monkeypatch.setattr(
706
+ "arcsecond.imagesources.sources.opencv.detect_webcams",
707
+ lambda *a, **k: [_device()],
708
+ )
709
+ _run(cli, webcam, ["add", "0"])
710
+
711
+ started = {}
712
+ monkeypatch.setattr(
713
+ "arcsecond.imagesources.proxy.run", lambda **k: started.update(k)
714
+ )
715
+ _run(cli, proxy, ["start", "--foreground"])
716
+ assert started["cameras"][0].specs == {"width": 1280, "height": 720, "fps": 30.0}
@@ -296,3 +296,74 @@ def test_migration_does_not_expand_the_password(store_file):
296
296
  store_file.write_text(json.dumps(V1))
297
297
  store.all_cameras(store_file)
298
298
  assert "${DOME_CAM_PW}" in store_file.read_text()
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # What probing learned about a USB camera
303
+ #
304
+ # Resolution and frame rate can only be measured by opening the device, and
305
+ # `/detect` must stay answerable while a camera is busy or unplugged. So they
306
+ # are measured once, at registration, and read back from the store afterwards.
307
+ # ---------------------------------------------------------------------------
308
+
309
+
310
+ SPECS = {"width": 1280, "height": 720, "fps": 30.0}
311
+
312
+
313
+ def test_a_usb_camera_remembers_what_probing_measured(store_file):
314
+ stored, _ = store.add(Camera(id="", kind=USB, index=0, specs=SPECS), store_file)
315
+ assert store.find(stored.id, store_file).specs == SPECS
316
+
317
+
318
+ def test_specs_survive_the_round_trip_to_disk(store_file):
319
+ store.add(Camera(id="", kind=USB, index=0, specs=SPECS), store_file)
320
+ written = json.loads(store_file.read_text())
321
+ assert written["cameras"][store.all_cameras(store_file)[0].id]["specs"] == SPECS
322
+
323
+
324
+ def test_a_camera_registered_while_unplugged_simply_has_none(store_file):
325
+ stored, _ = store.add(_usb(0), store_file)
326
+ assert store.find(stored.id, store_file).specs is None
327
+
328
+
329
+ def test_re_registering_records_specs_measured_the_second_time(store_file):
330
+ """Plugging the camera in and re-running `add` is how they get filled in."""
331
+ first, _ = store.add(_usb(0), store_file)
332
+ second, created = store.add(
333
+ Camera(id="", kind=USB, index=0, specs=SPECS), store_file
334
+ )
335
+ assert created is False
336
+ assert second.id == first.id
337
+ assert store.find(first.id, store_file).specs == SPECS
338
+
339
+
340
+ def test_re_registering_without_specs_does_not_wipe_the_recorded_ones(store_file):
341
+ """A re-run on a machine without OpenCV must not lose what was measured."""
342
+ stored, _ = store.add(Camera(id="", kind=USB, index=0, specs=SPECS), store_file)
343
+ store.add(_usb(0), store_file)
344
+ assert store.find(stored.id, store_file).specs == SPECS
345
+
346
+
347
+ def test_specs_are_not_part_of_what_identifies_a_camera(store_file):
348
+ """A camera switched to another mode is the same camera, not a new one."""
349
+ first, _ = store.add(Camera(id="", kind=USB, index=0, specs=SPECS), store_file)
350
+ other = dict(SPECS, width=640, height=480)
351
+ second, created = store.add(
352
+ Camera(id="", kind=USB, index=0, specs=other), store_file
353
+ )
354
+ assert (created, second.id) == (False, first.id)
355
+ assert len(store.all_cameras(store_file)) == 1
356
+ assert store.find(first.id, store_file).specs == other
357
+
358
+
359
+ def test_a_malformed_specs_entry_is_ignored_rather_than_fatal(store_file):
360
+ store_file.write_text(
361
+ json.dumps(
362
+ {
363
+ "version": 2,
364
+ "cameras": {"aaa": {"kind": "usb", "index": 0, "specs": "1280x720"}},
365
+ }
366
+ )
367
+ )
368
+ (camera,) = store.all_cameras(store_file)
369
+ assert camera.specs is None
@@ -154,3 +154,19 @@ def test_open_raises_when_the_device_will_not_open():
154
154
  assert "device index 0" in str(e)
155
155
  else:
156
156
  raise AssertionError("expected open() to raise")
157
+
158
+
159
+ def test_info_reports_the_recorded_resolution_without_opening_anything():
160
+ source = OpenCVWebcamSource(
161
+ 0, source_id="k3f", specs={"width": 1280, "height": 720, "fps": 30.0}
162
+ )
163
+ # No cv2 in sys.modules for this call: reporting must not need the device.
164
+ extra = source.info().extra
165
+ assert (extra["width"], extra["height"], extra["fps"]) == (1280, 720, 30.0)
166
+ assert extra["transport"] == "usb"
167
+
168
+
169
+ def test_info_omits_what_was_never_measured():
170
+ extra = OpenCVWebcamSource(0, source_id="k3f").info().extra
171
+ assert "width" not in extra and "fps" not in extra
172
+ assert extra == {"transport": "usb", "index": 0}
@@ -155,7 +155,7 @@ wheels = [
155
155
 
156
156
  [[package]]
157
157
  name = "arcsecond"
158
- version = "3.17.0"
158
+ version = "3.17.2"
159
159
  source = { editable = "." }
160
160
  dependencies = [
161
161
  { name = "alpyca" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes