piwave 2.0.8__py3-none-any.whl → 2.1.0__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.
piwave/piwave.py CHANGED
@@ -1,5 +1,6 @@
1
- # piwave/pw.py
2
- # pi_fm_rds is required !!! Check https://github.com/ChristopheJacquet/PiFmRds
1
+ # PiWave is available at https://piwave.xyz
2
+ # Licensed under GPLv3.0, main GitHub repository at https://github.com/douxxtech/piwave/
3
+ # piwave/piwave.py : main entry
3
4
 
4
5
  import os
5
6
  import subprocess
@@ -15,99 +16,8 @@ from pathlib import Path
15
16
  from urllib.parse import urlparse
16
17
  import atexit
17
18
 
18
- class Log:
19
- COLORS = { # absolutely not taken from stackoverflow trust
20
- 'reset': '\033[0m',
21
- 'bold': '\033[1m',
22
- 'underline': '\033[4m',
23
- 'red': '\033[31m',
24
- 'green': '\033[32m',
25
- 'yellow': '\033[33m',
26
- 'blue': '\033[34m',
27
- 'magenta': '\033[35m',
28
- 'cyan': '\033[36m',
29
- 'white': '\033[37m',
30
- 'bright_red': '\033[91m',
31
- 'bright_green': '\033[92m',
32
- 'bright_yellow': '\033[93m',
33
- 'bright_blue': '\033[94m',
34
- 'bright_magenta': '\033[95m',
35
- 'bright_cyan': '\033[96m',
36
- 'bright_white': '\033[97m',
37
- }
38
-
39
- ICONS = {
40
- 'success': 'OK',
41
- 'error': 'ERR',
42
- 'warning': 'WARN',
43
- 'info': 'INFO',
44
- 'client': 'CLIENT',
45
- 'server': 'SERVER',
46
- 'file': 'FILE',
47
- 'broadcast': 'BCAST',
48
- 'version': 'VER',
49
- 'update': 'UPD',
50
- }
51
-
52
- SILENT = False
53
-
54
- @classmethod
55
- def config(cls, silent: bool = False):
56
- cls.SILENT = silent
57
-
58
- @classmethod
59
- def print(cls, message: str, style: str = '', icon: str = '', end: str = '\n'):
60
-
61
- if cls.SILENT: return
62
-
63
- color = cls.COLORS.get(style, '')
64
- icon_char = cls.ICONS.get(icon, '')
65
- if icon_char:
66
- if color:
67
- print(f"{color}[{icon_char}]\033[0m {message}", end=end)
68
- else:
69
- print(f"[{icon_char}] {message}", end=end)
70
- else:
71
- if color:
72
- print(f"{color}{message}\033[0m", end=end)
73
- else:
74
- print(f"{message}", end=end)
75
- sys.stdout.flush()
76
-
77
- @classmethod
78
- def header(cls, text: str):
79
- cls.print(text, 'bright_blue', end='\n\n')
80
- sys.stdout.flush()
81
-
82
- @classmethod
83
- def section(cls, text: str):
84
- cls.print(f" {text} ", 'bright_blue', end='')
85
- cls.print("─" * (len(text) + 2), 'blue', end='\n\n')
86
- sys.stdout.flush()
87
-
88
- @classmethod
89
- def success(cls, message: str):
90
- cls.print(message, 'bright_green', 'success')
91
-
92
- @classmethod
93
- def error(cls, message: str):
94
- cls.print(message, 'bright_red', 'error')
95
-
96
- @classmethod
97
- def warning(cls, message: str):
98
- cls.print(message, 'bright_yellow', 'warning')
99
-
100
- @classmethod
101
- def info(cls, message: str):
102
- cls.print(message, 'bright_cyan', 'info')
103
-
104
- @classmethod
105
- def file_message(cls, message: str):
106
- cls.print(message, 'yellow', 'file')
107
-
108
- @classmethod
109
- def broadcast_message(cls, message: str):
110
- cls.print(message, 'bright_magenta', 'broadcast')
19
+ from .backends import discover_backends, backends, get_best_backend, search_backends, list_backends
20
+ from .logger import Log
111
21
 
112
22
  class PiWaveError(Exception):
113
23
  pass
@@ -120,6 +30,8 @@ class PiWave:
120
30
  pi: str = "FFFF",
121
31
  debug: bool = False,
122
32
  silent: bool = False,
33
+ loop: bool = False,
34
+ backend: str = "auto",
123
35
  on_track_change: Optional[Callable] = None,
124
36
  on_error: Optional[Callable] = None):
125
37
  """Initialize PiWave FM transmitter.
@@ -136,6 +48,10 @@ class PiWave:
136
48
  :type debug: bool
137
49
  :param silent: Removes every output log
138
50
  :type silent: bool
51
+ :param loop: Loop the current track continuously (default: False)
52
+ :type loop: bool
53
+ :param backend: Chose a specific backend to handle the broadcast (default: auto)
54
+ :type backend: str
139
55
  :param on_track_change: Callback function called when track changes
140
56
  :type on_track_change: Optional[Callable]
141
57
  :param on_error: Callback function called when an error occurs
@@ -152,29 +68,56 @@ class PiWave:
152
68
  self.ps = str(ps)[:8]
153
69
  self.rt = str(rt)[:64]
154
70
  self.pi = str(pi).upper()[:4]
71
+ self.loop = loop
155
72
  self.on_track_change = on_track_change
156
73
  self.on_error = on_error
157
74
 
158
75
  self.current_file: Optional[str] = None
159
76
  self.is_playing = False
160
77
  self.is_stopped = False
161
-
162
78
  self.current_process: Optional[subprocess.Popen] = None
163
79
  self.playback_thread: Optional[threading.Thread] = None
164
80
  self.stop_event = threading.Event()
165
-
166
81
  self.temp_dir = tempfile.mkdtemp(prefix="piwave_")
167
-
168
- Log.config(silent=silent)
169
-
170
- self.pi_fm_rds_path = self._find_pi_fm_rds_path()
171
82
 
83
+ Log.config(silent=silent)
84
+
172
85
  self._validate_environment()
173
-
174
86
  atexit.register(self.cleanup)
87
+
88
+ discover_backends()
175
89
 
90
+ if backend == "auto":
91
+ backend_name = get_best_backend("file_broadcast", self.frequency)
92
+ if not backend_name:
93
+ available = list(backends.keys())
94
+ raise PiWaveError(f"No suitable backend found for {self.frequency}MHz. Available backends: {available}")
95
+ else:
96
+ if backend not in backends:
97
+ available = list(backends.keys())
98
+ raise PiWaveError(f"Backend '{backend}' not available. Available: {available}. Use 'python3 -m piwave search' to refresh.")
99
+
100
+ # Validate that the chosen backend supports the frequency
101
+ backend_instance = backends[backend]()
102
+ min_freq, max_freq = backend_instance.frequency_range
103
+ if not (min_freq <= self.frequency <= max_freq):
104
+ raise PiWaveError(f"Backend '{backend}' doesn't support {self.frequency}MHz (supports {min_freq}-{max_freq}MHz)")
105
+
106
+ backend_name = backend
107
+
108
+ self.backend_name = backend_name
109
+ self.backend = backends[backend_name](
110
+ frequency=self.frequency,
111
+ ps=self.ps,
112
+ rt=self.rt,
113
+ pi=self.pi
114
+ )
115
+
116
+ min_freq, max_freq = self.backend.frequency_range
117
+ rds_support = "with RDS" if self.backend.supports_rds else "no RDS"
118
+ Log.info(f"Using {self.backend.name} backend ({min_freq}-{max_freq}MHz, {rds_support})")
176
119
 
177
- Log.info(f"PiWave initialized - Frequency: {frequency}MHz, PS: {ps}")
120
+ Log.info(f"PiWave initialized - Frequency: {frequency}MHz, PS: {ps}, Loop: {loop}")
178
121
 
179
122
  def _log_debug(self, message: str):
180
123
  if self.debug:
@@ -321,40 +264,48 @@ class PiWave:
321
264
  Log.error(f"Could not determine duration for {wav_file}")
322
265
  return False
323
266
 
324
- cmd = [
325
- 'sudo', self.pi_fm_rds_path,
326
- '-freq', str(self.frequency),
327
- '-ps', self.ps,
328
- '-rt', self.rt,
329
- '-pi', self.pi,
330
- '-audio', wav_file
331
- ]
332
-
333
267
  try:
334
- Log.broadcast_message(f"Playing {wav_file} (Duration: {duration:.1f}s) at {self.frequency}MHz")
335
- self.current_process = subprocess.Popen(
336
- cmd,
337
- stdout=subprocess.PIPE,
338
- stderr=subprocess.PIPE,
339
- preexec_fn=os.setsid
340
- )
268
+ # update settings
269
+ self.backend.frequency = self.frequency
270
+ self.backend.ps = self.ps
271
+ self.backend.rt = self.rt
272
+ self.backend.pi = self.pi
273
+
274
+ # validate frequency
275
+ min_freq, max_freq = self.backend.frequency_range
276
+ if not (min_freq <= self.frequency <= max_freq):
277
+ raise PiWaveError(f"Current backend '{self.backend.name}' doesn't support {self.frequency}MHz (supports {min_freq}-{max_freq}MHz). Use update() to change backend or frequency.")
278
+
279
+
280
+ loop_status = "looping" if self.loop else f"Duration: {duration:.1f}s"
281
+ rds_info = f" (PS: {self.ps})" if self.backend.supports_rds and self.ps else ""
282
+ Log.broadcast_message(f"Playing {wav_file} ({loop_status}) at {self.frequency}MHz{rds_info}")
283
+
284
+ self.current_process = self.backend.play_file(wav_file)
341
285
 
342
286
  if self.on_track_change:
343
287
  self.on_track_change(wav_file)
344
288
 
345
- # wait for either
346
- # The duration to elapse (then kill the process), or
347
- # stop_event to be set (user requested stop)
348
- start_time = time.time()
349
- while True:
350
- if self.stop_event.wait(timeout=0.1):
351
- self._stop_current_process()
352
- return False
289
+ if self.loop:
290
+ while not self.stop_event.is_set():
291
+ if self.stop_event.wait(timeout=0.1):
292
+ self._stop_current_process()
293
+ return False
294
+
295
+ if self.current_process.poll() is not None:
296
+ Log.error("Process ended unexpectedly while looping")
297
+ return False
298
+ else:
299
+ start_time = time.time()
300
+ while True:
301
+ if self.stop_event.wait(timeout=0.1):
302
+ self._stop_current_process()
303
+ return False
353
304
 
354
- elapsed = time.time() - start_time
355
- if elapsed >= duration:
356
- self._stop_current_process()
357
- break
305
+ elapsed = time.time() - start_time
306
+ if elapsed >= duration:
307
+ self._stop_current_process()
308
+ break
358
309
 
359
310
  return True
360
311
 
@@ -424,7 +375,8 @@ class PiWave:
424
375
 
425
376
  .. note::
426
377
  Files are automatically converted to WAV format if needed.
427
- Only local files are supported.
378
+ Only local files are supported. If loop is enabled, the file will
379
+ repeat continuously until stop() is called.
428
380
 
429
381
  Example:
430
382
  >>> pw.play('song.mp3')
@@ -509,6 +461,8 @@ class PiWave:
509
461
  pi: Optional[str] = None,
510
462
  debug: Optional[bool] = None,
511
463
  silent: Optional[bool] = None,
464
+ loop: Optional[bool] = None,
465
+ backend: Optional[str] = None,
512
466
  on_track_change: Optional[Callable] = None,
513
467
  on_error: Optional[Callable] = None):
514
468
  """Update PiWave settings.
@@ -525,6 +479,10 @@ class PiWave:
525
479
  :type debug: Optional[bool]
526
480
  :param silent: Remove every output log
527
481
  :type silent: Optional[bool]
482
+ :param loop: Loop the current track continuously
483
+ :type loop: Optional[bool]
484
+ :param backend: Backend used to broadcast
485
+ :type backend: Optional[str]
528
486
  :param on_track_change: Callback function called when track changes
529
487
  :type on_track_change: Optional[Callable]
530
488
  :param on_error: Callback function called when an error occurs
@@ -535,9 +493,38 @@ class PiWave:
535
493
 
536
494
  Example:
537
495
  >>> pw.update(frequency=101.5, ps="NewName")
538
- >>> pw.update(rt="Updated radio text", debug=True)
496
+ >>> pw.update(rt="Updated radio text", debug=True, loop=True)
539
497
  """
540
498
  updated_settings = []
499
+
500
+ freq_to_use = frequency if frequency is not None else self.frequency
501
+
502
+ if backend is not None:
503
+ if backend == "auto":
504
+ backend_name = get_best_backend("file_broadcast", freq_to_use)
505
+ if not backend_name:
506
+ available = list(backends.keys())
507
+ raise PiWaveError(f"No suitable backend found for {freq_to_use}MHz. Available: {available}")
508
+ else:
509
+ if backend not in backends:
510
+ available = list(backends.keys())
511
+ raise PiWaveError(f"Backend '{backend}' not available. Available: {available}")
512
+ backend_name = backend
513
+
514
+ backend_instance = backends[backend_name](
515
+ frequency=freq_to_use,
516
+ ps=ps or self.ps,
517
+ rt=rt or self.rt,
518
+ pi=pi or self.pi
519
+ )
520
+
521
+ min_freq, max_freq = backend_instance.frequency_range
522
+ if not (min_freq <= freq_to_use <= max_freq):
523
+ raise PiWaveError(f"Backend '{backend_name}' doesn't support {freq_to_use}MHz (supports {min_freq}-{max_freq}MHz)")
524
+
525
+ self.backend_name = backend_name
526
+ self.backend = backend_instance
527
+ updated_settings.append(f"backend: {backend_name}")
541
528
 
542
529
  if frequency is not None:
543
530
  self.frequency = frequency
@@ -563,6 +550,10 @@ class PiWave:
563
550
  Log.config(silent=silent)
564
551
  updated_settings.append(f"silent: {silent}")
565
552
 
553
+ if loop is not None:
554
+ self.loop = loop
555
+ updated_settings.append(f"loop: {loop}")
556
+
566
557
  if on_track_change is not None:
567
558
  self.on_track_change = on_track_change
568
559
  updated_settings.append("on_track_change callback updated")
@@ -591,6 +582,23 @@ class PiWave:
591
582
  self.frequency = frequency
592
583
  Log.broadcast_message(f"Frequency changed to {frequency}MHz. Will update on next file's broadcast.")
593
584
 
585
+ def set_loop(self, loop: bool):
586
+ """Enable or disable looping for the current track.
587
+
588
+ :param loop: True to enable looping, False to disable
589
+ :type loop: bool
590
+
591
+ .. note::
592
+ The loop setting will take effect on the next broadcast.
593
+
594
+ Example:
595
+ >>> pw.set_loop(True) # Enable looping
596
+ >>> pw.set_loop(False) # Disable looping
597
+ """
598
+ self.loop = loop
599
+ loop_status = "enabled" if loop else "disabled"
600
+ Log.broadcast_message(f"Looping {loop_status}. Will update on next file's broadcast.")
601
+
594
602
  def get_status(self) -> dict:
595
603
  """Get current status information.
596
604
 
@@ -602,22 +610,33 @@ class PiWave:
602
610
  - **is_playing** (bool): Whether playback is active
603
611
  - **frequency** (float): Current broadcast frequency
604
612
  - **current_file** (str|None): Path of currently playing file
613
+ - **current_backend** (str): Currently used backend
614
+ - **backend_frequency_range** (str): Frequency range supported by the backend
615
+ - **backend_supports_rds** (bool): Backend support of Radio Data System
616
+ - **avalible_backends** (list): List of avalible backends
605
617
  - **ps** (str): Program Service name
606
618
  - **rt** (str): Radio Text message
607
619
  - **pi** (str): Program Identification code
620
+ - **loop** (bool): Whether looping is enabled
608
621
 
609
622
  Example:
610
623
  >>> status = pw.get_status()
611
624
  >>> print(f"Playing: {status['is_playing']}")
612
625
  >>> print(f"Current file: {status['current_file']}")
626
+ >>> print(f"Looping: {status['loop']}")
613
627
  """
614
628
  return {
615
629
  'is_playing': self.is_playing,
616
630
  'frequency': self.frequency,
617
631
  'current_file': self.current_file,
632
+ 'current_backend': self.backend_name,
633
+ 'backend_frequency_range': f"{self.backend.frequency_range[0]}-{self.backend.frequency_range[1]}MHz",
634
+ 'backend_supports_rds': self.backend.supports_rds,
635
+ 'available_backends': list(backends.keys()),
618
636
  'ps': self.ps,
619
637
  'rt': self.rt,
620
- 'pi': self.pi
638
+ 'pi': self.pi,
639
+ 'loop': self.loop
621
640
  }
622
641
 
623
642
  def cleanup(self):