bluer-ugv 7.353.1__py3-none-any.whl → 7.421.1__py3-none-any.whl

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 (38) hide show
  1. bluer_ugv/.abcli/swallow/dataset.sh +1 -1
  2. bluer_ugv/.abcli/swallow/video/play.sh +7 -0
  3. bluer_ugv/.abcli/swallow/video/playlist/cat.sh +14 -0
  4. bluer_ugv/.abcli/swallow/video/playlist/download.sh +18 -0
  5. bluer_ugv/.abcli/swallow/video/playlist/edit.sh +14 -0
  6. bluer_ugv/.abcli/swallow/video/playlist/upload.sh +9 -0
  7. bluer_ugv/.abcli/swallow/video/playlist.sh +15 -0
  8. bluer_ugv/.abcli/swallow/video.sh +15 -0
  9. bluer_ugv/.abcli/swallow.sh +1 -1
  10. bluer_ugv/.abcli/tests/help.sh +8 -0
  11. bluer_ugv/.abcli/tests/swallow_video_play.sh +11 -0
  12. bluer_ugv/.abcli/tests/swallow_video_playlist.sh +45 -0
  13. bluer_ugv/__init__.py +1 -1
  14. bluer_ugv/config.env +4 -2
  15. bluer_ugv/env.py +2 -0
  16. bluer_ugv/help/swallow/__init__.py +2 -0
  17. bluer_ugv/help/swallow/video/__init__.py +0 -0
  18. bluer_ugv/help/swallow/video/functions.py +41 -0
  19. bluer_ugv/help/swallow/video/playlist.py +89 -0
  20. bluer_ugv/swallow/session/classical/screen/__init__.py +0 -0
  21. bluer_ugv/swallow/session/classical/screen/classes.py +42 -0
  22. bluer_ugv/swallow/session/classical/screen/video/__init__.py +0 -0
  23. bluer_ugv/swallow/session/classical/screen/video/__main__.py +99 -0
  24. bluer_ugv/swallow/session/classical/screen/video/engine.py +125 -0
  25. bluer_ugv/swallow/session/classical/screen/video/player.py +117 -0
  26. bluer_ugv/swallow/session/classical/screen/video/playlist.py +116 -0
  27. bluer_ugv/swallow/session/classical/session.py +11 -7
  28. bluer_ugv/swallow/session/classical/ultrasonic_sensor/classes.py +11 -16
  29. bluer_ugv/swallow/session/classical/ultrasonic_sensor/detection_list.py +45 -0
  30. bluer_ugv/swallow/session/classical/ultrasonic_sensor/log.py +22 -16
  31. bluer_ugv/swallow/session/classical/ultrasonic_sensor/pack.py +16 -4
  32. bluer_ugv/swallow/session/classical/ultrasonic_sensor/testing.py +2 -2
  33. {bluer_ugv-7.353.1.dist-info → bluer_ugv-7.421.1.dist-info}/METADATA +2 -2
  34. {bluer_ugv-7.353.1.dist-info → bluer_ugv-7.421.1.dist-info}/RECORD +37 -18
  35. bluer_ugv/swallow/session/classical/screen.py +0 -11
  36. {bluer_ugv-7.353.1.dist-info → bluer_ugv-7.421.1.dist-info}/WHEEL +0 -0
  37. {bluer_ugv-7.353.1.dist-info → bluer_ugv-7.421.1.dist-info}/licenses/LICENSE +0 -0
  38. {bluer_ugv-7.353.1.dist-info → bluer_ugv-7.421.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,117 @@
1
+ from typing import List, Optional
2
+ import subprocess
3
+ import shlex
4
+ import time
5
+
6
+ from bluer_options.logger import crash_report
7
+ from bluer_options.logger.config import log_list
8
+ from bluer_objects import file
9
+
10
+ from bluer_ugv.swallow.session.classical.screen.video.engine import VideoEngine
11
+ from bluer_ugv.logger import logger
12
+
13
+
14
+ class VideoPlayer:
15
+ def __init__(
16
+ self,
17
+ dryrun: bool = False,
18
+ engine: VideoEngine = VideoEngine.VLC,
19
+ ):
20
+ self.process: Optional[subprocess.Popen] = None
21
+ self.current_file: Optional[str] = None
22
+
23
+ self.paused = False
24
+ self.dryrun = dryrun
25
+ assert isinstance(engine, VideoEngine)
26
+ self.engine: VideoEngine = engine
27
+
28
+ logger.info(
29
+ "{} created on {}{}.".format(
30
+ self.__class__.__name__,
31
+ self.engine.name.lower(),
32
+ " [dryrun]" if dryrun else "",
33
+ )
34
+ )
35
+
36
+ def pause(self) -> bool:
37
+ if not self.dryrun:
38
+ if self.process and self.process.poll() is None:
39
+ if not self.engine.pause(self.process):
40
+ return False
41
+
42
+ logger.info(
43
+ "{}.{}".format(
44
+ self.__class__.__name__,
45
+ "resume" if self.paused else "pause",
46
+ )
47
+ )
48
+ self.paused = not self.paused
49
+
50
+ return True
51
+
52
+ def play(
53
+ self,
54
+ filename: str,
55
+ loop: bool = False,
56
+ audio: bool = False,
57
+ fullscreen: bool = True,
58
+ verbose: bool = True,
59
+ ) -> bool:
60
+ if not file.exists(filename):
61
+ logger.error(f"file not found: {filename}")
62
+ return False
63
+
64
+ self.stop()
65
+
66
+ command = self.engine.play_command(
67
+ filename=filename,
68
+ fullscreen=fullscreen,
69
+ loop=loop,
70
+ audio=audio,
71
+ )
72
+ logger.info(f"running on {self.engine.name.lower()}: {command}")
73
+
74
+ if not self.dryrun:
75
+ try:
76
+ # pylint: disable=consider-using-with
77
+ self.process = subprocess.Popen(
78
+ shlex.split(command),
79
+ stdin=subprocess.PIPE if self.engine == VideoEngine.MPV else None,
80
+ stdout=None if verbose else subprocess.DEVNULL,
81
+ stderr=None if verbose else subprocess.DEVNULL,
82
+ )
83
+
84
+ logger.info(
85
+ f"pid={self.process.pid}, "
86
+ f"stdin={self.process.stdin}, returncode={self.process.returncode}"
87
+ )
88
+
89
+ except Exception as e:
90
+ crash_report(f"failed to run: {e}")
91
+ self.process = None
92
+ return False
93
+
94
+ if not self.process:
95
+ logger.error("process is None.")
96
+ return False
97
+
98
+ self.current_file = filename
99
+
100
+ logger.info(
101
+ "{}.play({}{})".format(
102
+ self.__class__.__name__,
103
+ "loop: " if loop else "",
104
+ filename,
105
+ )
106
+ )
107
+
108
+ return True
109
+
110
+ def stop(self) -> bool:
111
+ if not self.dryrun and self.process and self.process.poll() is None:
112
+ if not self.engine.stop(self.process):
113
+ return False
114
+
115
+ self.process = None
116
+ logger.info(f"{self.__class__.__name__}.stop")
117
+ return True
@@ -0,0 +1,116 @@
1
+ from typing import Dict, List
2
+
3
+ from bluer_options.logger.config import log_dict, log_list
4
+ from bluer_objects.metadata import get_from_object
5
+ from bluer_objects import storage, objects
6
+ from bluer_objects.storage.policies import DownloadPolicy
7
+
8
+ from bluer_ugv.logger import logger
9
+
10
+
11
+ class PlayList:
12
+ def __init__(
13
+ self,
14
+ object_name: str,
15
+ download: bool = True,
16
+ ):
17
+ self.index: int = -1
18
+
19
+ self.object_name = object_name
20
+ self.download = download
21
+
22
+ if self.download:
23
+ storage.download(
24
+ self.object_name,
25
+ filename="metadata.yaml",
26
+ )
27
+
28
+ self.messages: Dict[str, str] = get_from_object(
29
+ self.object_name,
30
+ "messages",
31
+ default={},
32
+ )
33
+ log_dict(
34
+ logger,
35
+ "loaded",
36
+ self.messages,
37
+ "message(s)",
38
+ max_count=-1,
39
+ max_length=-1,
40
+ )
41
+
42
+ self.playlist: List[Dict[str, str]] = get_from_object(
43
+ self.object_name,
44
+ "playlist",
45
+ default=[],
46
+ )
47
+ log_list(
48
+ logger,
49
+ "loaded",
50
+ self.playlist,
51
+ "playlist item(s)",
52
+ max_count=-1,
53
+ max_length=-1,
54
+ )
55
+
56
+ logger.info(
57
+ "{} created from {}.".format(
58
+ self.__class__.__name__,
59
+ self.object_name,
60
+ )
61
+ )
62
+
63
+ def get(
64
+ self,
65
+ keyword: int | str = "loading",
66
+ what: str = "filename",
67
+ ) -> str:
68
+ filename = f"{keyword.__class__.__name__}-not-supported"
69
+
70
+ if isinstance(keyword, int):
71
+ filename = "bad-index-{}-from-{}".format(
72
+ keyword,
73
+ len(self.playlist),
74
+ )
75
+
76
+ if 0 <= keyword < len(self.playlist):
77
+ filename = self.playlist[keyword].get(
78
+ what,
79
+ f"{what}-not-found",
80
+ )
81
+
82
+ if isinstance(keyword, str):
83
+ filename = f"{keyword}-not-found"
84
+
85
+ if keyword.isnumeric():
86
+ return self.get(int(keyword), what=what)
87
+
88
+ if keyword in self.messages:
89
+ filename = self.messages[keyword].get(
90
+ what,
91
+ f"{what}-not-found",
92
+ )
93
+
94
+ if self.download:
95
+ storage.download(
96
+ self.object_name,
97
+ filename=filename,
98
+ policy=DownloadPolicy.DOESNT_EXIST,
99
+ )
100
+
101
+ return objects.path_of(
102
+ filename=filename,
103
+ object_name=self.object_name,
104
+ )
105
+
106
+ def next(self):
107
+ self.index += 1
108
+ if self.index >= len(self.playlist):
109
+ self.index = 0
110
+
111
+ logger.info(
112
+ "{}: video #{}".format(
113
+ self.__class__.__name__,
114
+ self.index,
115
+ )
116
+ )
@@ -24,7 +24,7 @@ from bluer_ugv.swallow.session.classical.motor import (
24
24
  )
25
25
  from bluer_ugv.swallow.session.classical.setpoint.classes import ClassicalSetPoint
26
26
  from bluer_ugv.swallow.session.classical.position import ClassicalPosition
27
- from bluer_ugv.swallow.session.classical.screen import ClassicalScreen
27
+ from bluer_ugv.swallow.session.classical.screen.classes import ClassicalScreen
28
28
  from bluer_ugv.swallow.session.classical.ultrasonic_sensor.classes import (
29
29
  ClassicalUltrasonicSensor,
30
30
  )
@@ -114,6 +114,8 @@ class ClassicalSession:
114
114
  object_name=self.object_name,
115
115
  )
116
116
 
117
+ self.screen = ClassicalScreen()
118
+
117
119
  logger.info(
118
120
  "{}: created for {}".format(
119
121
  self.__class__.__name__,
@@ -127,12 +129,10 @@ class ClassicalSession:
127
129
  self.ultrasonic_sensor.stop()
128
130
  self.camera.stop()
129
131
 
130
- for thing in [
131
- self.motor1,
132
- self.motor2,
133
- self.camera,
134
- ]:
135
- thing.cleanup()
132
+ self.motor1.cleanup()
133
+ self.motor2.cleanup()
134
+
135
+ self.camera.cleanup()
136
136
 
137
137
  GPIO.cleanup()
138
138
 
@@ -156,12 +156,15 @@ class ClassicalSession:
156
156
  loop_frequency,
157
157
  )
158
158
 
159
+ self.screen.cleanup()
160
+
159
161
  logger.info(f"{self.__class__.__name__}.cleanup")
160
162
 
161
163
  def initialize(self) -> bool:
162
164
  return all(
163
165
  thing.initialize()
164
166
  for thing in [
167
+ self.screen,
165
168
  self.push_button,
166
169
  self.leds,
167
170
  self.motor1,
@@ -183,6 +186,7 @@ class ClassicalSession:
183
186
  self.motor1,
184
187
  self.motor2,
185
188
  self.leds,
189
+ self.screen,
186
190
  ]:
187
191
  self.timing.start(thing.__class__.__name__)
188
192
 
@@ -10,6 +10,9 @@ from bluer_ugv.swallow.session.classical.ultrasonic_sensor.pack import (
10
10
  from bluer_ugv.swallow.session.classical.ultrasonic_sensor.detection import (
11
11
  DetectionState,
12
12
  )
13
+ from bluer_ugv.swallow.session.classical.ultrasonic_sensor.detection_list import (
14
+ DetectionList,
15
+ )
13
16
  from bluer_ugv.swallow.session.classical.ultrasonic_sensor.log import (
14
17
  UltrasonicSensorDetectionLog,
15
18
  )
@@ -42,6 +45,8 @@ class ClassicalUltrasonicSensor:
42
45
  self.setpoint = setpoint
43
46
  self.keyboard = keyboard
44
47
 
48
+ self.detection_list = DetectionList()
49
+
45
50
  self.pack = None
46
51
  self.log = None
47
52
  self.running = False
@@ -85,30 +90,22 @@ class ClassicalUltrasonicSensor:
85
90
  time.sleep(0.01)
86
91
  continue
87
92
 
88
- success, detections = self.pack.detect(
89
- log=env.BLUER_UGV_ULTRASONIC_SENSOR_LOG == 1
93
+ success, self.detection_list = self.pack.detect(
94
+ log=env.BLUER_UGV_ULTRASONIC_SENSOR_LOG == 1,
90
95
  )
91
96
  if not success:
92
97
  raise NameError("failed to detect ultrasonic sensor.")
93
98
 
94
99
  if self.log is not None:
95
- self.log.append(detections)
100
+ self.log.append(self.detection_list)
96
101
 
97
102
  log_detections: bool = False
98
103
  speed = self.setpoint.get(what="speed")
99
- if any(
100
- detection.state == DetectionState.DANGER for detection in detections
101
- ):
104
+ if self.detection_list.state == DetectionState.DANGER:
102
105
  self.setpoint.stop()
103
106
  log_detections = True
104
107
  logger.info("⛔️ danger detected, stopping.")
105
- elif (
106
- any(
107
- detection.state == DetectionState.WARNING
108
- for detection in detections
109
- )
110
- and speed > 0
111
- ):
108
+ elif self.detection_list.state == DetectionState.WARNING and speed > 0:
112
109
  self.setpoint.put(
113
110
  what="speed",
114
111
  value=speed // 2,
@@ -123,8 +120,6 @@ class ClassicalUltrasonicSensor:
123
120
  logger.info(
124
121
  "{}: {}".format(
125
122
  self.__class__.__name__,
126
- ", ".join(
127
- [detection.as_str(short=True) for detection in detections]
128
- ),
123
+ ", ".join(self.detection_list.as_str(short=True)),
129
124
  )
130
125
  )
@@ -0,0 +1,45 @@
1
+ from typing import List, Iterable, Iterator
2
+ from bluer_ugv.swallow.session.classical.ultrasonic_sensor.detection import (
3
+ Detection,
4
+ DetectionState,
5
+ )
6
+
7
+
8
+ class DetectionList:
9
+ def __init__(
10
+ self,
11
+ list_of_detections: Iterable[Detection] | None = None,
12
+ ):
13
+ self._content: List[Detection] = (
14
+ list(list_of_detections) if list_of_detections else []
15
+ )
16
+
17
+ def __iter__(self) -> Iterator[Detection]:
18
+ return iter(self._content)
19
+
20
+ def __len__(self) -> int:
21
+ return len(self._content)
22
+
23
+ def __getitem__(self, index: int) -> Detection:
24
+ return self._content[index]
25
+
26
+ def append(self, detection: Detection) -> None:
27
+ self._content.append(detection)
28
+
29
+ def as_str(
30
+ self,
31
+ short: bool = False,
32
+ ) -> List[str]:
33
+ return [detection.as_str(short=short) for detection in self._content]
34
+
35
+ @property
36
+ def state(self) -> DetectionState:
37
+ if any(detection.state == DetectionState.DANGER for detection in self._content):
38
+ return DetectionState.DANGER
39
+
40
+ if any(
41
+ detection.state == DetectionState.WARNING for detection in self._content
42
+ ):
43
+ return DetectionState.WARNING
44
+
45
+ return DetectionState.CLEAR
@@ -1,4 +1,7 @@
1
1
  from typing import List
2
+ import matplotlib
3
+
4
+ matplotlib.use("Agg")
2
5
  import matplotlib.pyplot as plt
3
6
  from tqdm import tqdm
4
7
  import numpy as np
@@ -12,16 +15,19 @@ from bluer_objects.graphics.signature import add_signature
12
15
 
13
16
  from bluer_ugv import env
14
17
  from bluer_ugv.swallow.session.classical.ultrasonic_sensor.detection import Detection
18
+ from bluer_ugv.swallow.session.classical.ultrasonic_sensor.detection_list import (
19
+ DetectionList,
20
+ )
15
21
  from bluer_ugv.host import signature
16
22
  from bluer_ugv.logger import logger
17
23
 
18
24
 
19
25
  class UltrasonicSensorDetectionLog:
20
26
  def __init__(self):
21
- self.log: List[List[Detection]] = []
27
+ self.log: List[DetectionList] = []
22
28
 
23
- def append(self, detection: List[Detection]):
24
- self.log.append(detection)
29
+ def append(self, detection_list: DetectionList):
30
+ self.log.append(detection_list)
25
31
 
26
32
  def export(
27
33
  self,
@@ -72,12 +78,12 @@ class UltrasonicSensorDetectionLog:
72
78
  )
73
79
 
74
80
  plt.plot(
75
- [func(list_of_detections[0]) for list_of_detections in self.log],
81
+ [func(detection_list[0]) for detection_list in self.log],
76
82
  color="green",
77
83
  label="left sensor",
78
84
  )
79
85
  plt.plot(
80
- [func(list_of_detections[1]) for list_of_detections in self.log],
86
+ [func(detection_list[1]) for detection_list in self.log],
81
87
  color="blue",
82
88
  label="right sensor",
83
89
  )
@@ -150,8 +156,8 @@ class UltrasonicSensorDetectionLog:
150
156
  ) -> bool:
151
157
  image = np.zeros((len(self.log[0]), len(self.log), 3), dtype=np.uint8)
152
158
 
153
- for detection_index, list_of_detections in enumerate(self.log):
154
- for sensor_index, detection in enumerate(list_of_detections):
159
+ for detection_index, detection_list in enumerate(self.log):
160
+ for sensor_index, detection in enumerate(detection_list):
155
161
  assert isinstance(detection, Detection)
156
162
 
157
163
  for channel in range(3):
@@ -213,12 +219,12 @@ class UltrasonicSensorDetectionLog:
213
219
  if not path.create(temp_folder):
214
220
  return False
215
221
 
216
- for index, detections in tqdm(enumerate(self.log)):
222
+ for index, detection_list in tqdm(enumerate(self.log)):
217
223
  if frame_count != -1 and len(image_list) >= frame_count:
218
224
  rm_blank_count += 1
219
225
  break
220
226
 
221
- if rm_blank and all(detection.is_blank for detection in detections):
227
+ if rm_blank and all(detection.is_blank for detection in detection_list):
222
228
  continue
223
229
 
224
230
  filename = objects.path_of(
@@ -230,12 +236,12 @@ class UltrasonicSensorDetectionLog:
230
236
  [
231
237
  detection.as_image(
232
238
  height=height,
233
- width=int(width / len(detections)),
239
+ width=int(width / len(detection_list)),
234
240
  max_m=max_m,
235
- line_width=int(line_width / len(detections)),
241
+ line_width=int(line_width / len(detection_list)),
236
242
  sign=False,
237
243
  )
238
- for detection in detections
244
+ for detection in detection_list
239
245
  ],
240
246
  axis=1,
241
247
  )
@@ -254,14 +260,14 @@ class UltrasonicSensorDetectionLog:
254
260
  env.BLUER_UGV_ULTRASONIC_SENSOR_DANGER_THRESHOLD
255
261
  ),
256
262
  ]
257
- + [detection.as_str(short=True) for detection in detections]
263
+ + detection_list.as_str(short=True)
258
264
  + objects.signature(
259
265
  "frame #{:04d}/{}".format(
260
266
  index,
261
267
  len(self.log),
262
268
  ),
263
269
  object_name,
264
- )
270
+ ),
265
271
  )
266
272
  ],
267
273
  footer=[" | ".join(signature())],
@@ -316,8 +322,8 @@ class UltrasonicSensorDetectionLog:
316
322
  ),
317
323
  {
318
324
  "detections": [
319
- [detection.as_dict() for detection in list_of_detections]
320
- for list_of_detections in self.log
325
+ [detection.as_dict() for detection in detection_list]
326
+ for detection_list in self.log
321
327
  ]
322
328
  },
323
329
  log=log,
@@ -4,7 +4,9 @@ from bluer_ugv import env
4
4
  from bluer_ugv.swallow.session.classical.ultrasonic_sensor.sensor import (
5
5
  lUltrasonicSensor,
6
6
  )
7
- from bluer_ugv.swallow.session.classical.ultrasonic_sensor.detection import Detection
7
+ from bluer_ugv.swallow.session.classical.ultrasonic_sensor.detection_list import (
8
+ DetectionList,
9
+ )
8
10
  from bluer_ugv.logger import logger
9
11
 
10
12
 
@@ -30,7 +32,7 @@ class UltrasonicSensorPack:
30
32
  def detect(
31
33
  self,
32
34
  log: bool = True,
33
- ) -> Tuple[bool, List[Detection]]:
35
+ ) -> Tuple[bool, DetectionList]:
34
36
  success_left, detection_left = self.left.detect(log=False)
35
37
  success_right, detection_right = self.right.detect(log=False)
36
38
 
@@ -45,6 +47,16 @@ class UltrasonicSensorPack:
45
47
  )
46
48
 
47
49
  return (
48
- success_left and success_right,
49
- [detection_left, detection_right],
50
+ all(
51
+ [
52
+ success_left,
53
+ success_right,
54
+ ]
55
+ ),
56
+ DetectionList(
57
+ [
58
+ detection_left,
59
+ detection_right,
60
+ ],
61
+ ),
50
62
  )
@@ -32,12 +32,12 @@ def test(
32
32
  success = True
33
33
  try:
34
34
  while True:
35
- success, detection = ultrasonic_sensor_pack.detect(log=log)
35
+ success, detection_list = ultrasonic_sensor_pack.detect(log=log)
36
36
  if not success:
37
37
  break
38
38
 
39
39
  if export:
40
- detection_log.append(detection)
40
+ detection_log.append(detection_list)
41
41
  except KeyboardInterrupt:
42
42
  logger.info("^C detected.")
43
43
  finally:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bluer_ugv
3
- Version: 7.353.1
3
+ Version: 7.421.1
4
4
  Summary: 🐬 AI x UGV.
5
5
  Home-page: https://github.com/kamangir/bluer-ugv
6
6
  Author: Arash Abadpour (Kamangir)
@@ -64,7 +64,7 @@ pip install bluer_ugv
64
64
 
65
65
  [![pylint](https://github.com/kamangir/bluer-ugv/actions/workflows/pylint.yml/badge.svg)](https://github.com/kamangir/bluer-ugv/actions/workflows/pylint.yml) [![pytest](https://github.com/kamangir/bluer-ugv/actions/workflows/pytest.yml/badge.svg)](https://github.com/kamangir/bluer-ugv/actions/workflows/pytest.yml) [![bashtest](https://github.com/kamangir/bluer-ugv/actions/workflows/bashtest.yml/badge.svg)](https://github.com/kamangir/bluer-ugv/actions/workflows/bashtest.yml) [![PyPI version](https://img.shields.io/pypi/v/bluer-ugv.svg)](https://pypi.org/project/bluer-ugv/) [![PyPI - Downloads](https://img.shields.io/pypi/dd/bluer-ugv)](https://pypistats.org/packages/bluer-ugv)
66
66
 
67
- built by 🌀 [`bluer README`](https://github.com/kamangir/bluer-objects/tree/main/bluer_objects/README), based on 🐬 [`bluer_ugv-7.353.1`](https://github.com/kamangir/bluer-ugv).
67
+ built by 🌀 [`bluer README`](https://github.com/kamangir/bluer-objects/tree/main/bluer_objects/README), based on 🐬 [`bluer_ugv-7.421.1`](https://github.com/kamangir/bluer-ugv).
68
68
 
69
69
 
70
70
  built by 🌀 [`blueness-3.118.1`](https://github.com/kamangir/blueness).