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/__init__.py +3 -1
- piwave/__main__.py +105 -0
- piwave/backends/__init__.py +119 -0
- piwave/backends/base.py +236 -0
- piwave/backends/fm_transmitter.py +33 -0
- piwave/backends/pi_fm_rds.py +42 -0
- piwave/logger.py +99 -0
- piwave/piwave.py +153 -134
- piwave-2.1.0.dist-info/METADATA +743 -0
- piwave-2.1.0.dist-info/RECORD +13 -0
- piwave-2.0.8.dist-info/METADATA +0 -419
- piwave-2.0.8.dist-info/RECORD +0 -7
- {piwave-2.0.8.dist-info → piwave-2.1.0.dist-info}/WHEEL +0 -0
- {piwave-2.0.8.dist-info → piwave-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {piwave-2.0.8.dist-info → piwave-2.1.0.dist-info}/top_level.txt +0 -0
piwave/piwave.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
# piwave
|
|
2
|
-
#
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
335
|
-
self.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
self.
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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):
|