piwave 2.0.9__py3-none-any.whl → 2.1.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.
piwave/__init__.py CHANGED
@@ -1,4 +1,6 @@
1
- # piwave/__init__.py
1
+ # PiWave is available at https://piwave.xyz
2
+ # Licensed under GPLv3.0, main GitHub repository at https://github.com/douxxtech/piwave/
3
+ # piwave/__init__.py : whats this again ?
2
4
 
3
5
  from .piwave import PiWave
4
6
 
piwave/__main__.py ADDED
@@ -0,0 +1,105 @@
1
+ # PiWave is available at https://piwave.xyz
2
+ # Licensed under GPLv3.0, main GitHub repository at https://github.com/douxxtech/piwave/
3
+ # piwave/__main__.py : handles cli inputs
4
+
5
+ import sys
6
+ import argparse
7
+ from .backends import search_backends, list_backends, backends, discover_backends
8
+ from .logger import Log
9
+ from .piwave import PiWave, PiWaveError
10
+
11
+ def main():
12
+ if len(sys.argv) < 2:
13
+ Log.header("PiWave CLI")
14
+ Log.info("Usage: python3 -m piwave <command> [args]\n")
15
+ Log.info("Available commands:")
16
+ Log.info(" search - Search for available backends")
17
+ Log.info(" list - List cached backends")
18
+ Log.info(" add <name> <path> - Manually add backend executable path")
19
+ Log.info(" info - Show package info")
20
+ Log.info(" broadcast <file> - Broadcast a file over FM")
21
+ sys.exit(0)
22
+
23
+ cmd = sys.argv[1].lower()
24
+
25
+ if cmd == "search":
26
+ search_backends()
27
+
28
+ elif cmd == "list":
29
+ discover_backends()
30
+ list_backends()
31
+
32
+ elif cmd == "add":
33
+ if len(sys.argv) < 4:
34
+ Log.error("Usage: python3 -m piwave add <backend_name> <path>")
35
+ sys.exit(1)
36
+
37
+ backend_name = sys.argv[2]
38
+ backend_path = sys.argv[3]
39
+
40
+ discover_backends()
41
+ if backend_name not in backends:
42
+ Log.error(f"Unknown backend '{backend_name}'. Run `python3 -m piwave search` first.")
43
+ sys.exit(1)
44
+
45
+ backend_class = backends[backend_name]
46
+ try:
47
+ backend = backend_class()
48
+ backend.cache_file.write_text(backend_path)
49
+ Log.success(f"Backend '{backend_name}' path set to {backend_path}")
50
+ except Exception as e:
51
+ Log.error(f"Failed to add backend '{backend_name}': {e}")
52
+
53
+ elif cmd == "info":
54
+ Log.header("PiWave Module Info")
55
+ Log.info("Python-based FM transmitter manager for Raspberry Pi")
56
+ Log.info("Commands: search, list, add, info, broadcast")
57
+
58
+ elif cmd == "broadcast":
59
+ parser = argparse.ArgumentParser(
60
+ prog="python3 -m piwave broadcast",
61
+ description="Broadcast a file using PiWave"
62
+ )
63
+ parser.add_argument("file", help="Path to the audio file to broadcast")
64
+ parser.add_argument("--frequency", type=float, default=90.0, help="FM frequency (MHz)")
65
+ parser.add_argument("--ps", type=str, default="PiWave", help="Program Service name (max 8 chars)")
66
+ parser.add_argument("--rt", type=str, default="PiWave: The best python module for managing your pi radio", help="Radio Text (max 64 chars)")
67
+ parser.add_argument("--pi", type=str, default="FFFF", help="Program Identification code (4 hex digits)")
68
+ parser.add_argument("--debug", action="store_true", help="Enable debug logging")
69
+ parser.add_argument("--silent", action="store_true", help="Suppress logs")
70
+ parser.add_argument("--loop", action="store_true", help="Loop playback")
71
+ parser.add_argument("--backend", type=str, default="auto", help="Choose a specific backend")
72
+
73
+ args = parser.parse_args(sys.argv[2:])
74
+
75
+ try:
76
+ pw = PiWave(
77
+ frequency=args.frequency,
78
+ ps=args.ps,
79
+ rt=args.rt,
80
+ pi=args.pi,
81
+ debug=args.debug,
82
+ silent=args.silent,
83
+ loop=args.loop,
84
+ backend=args.backend
85
+ )
86
+ pw.play(args.file)
87
+
88
+ Log.info("Press Ctrl+C to stop broadcasting...")
89
+ try:
90
+ while True:
91
+ pass
92
+ except KeyboardInterrupt:
93
+ pw.stop()
94
+ Log.info("Broadcast stopped")
95
+ except PiWaveError as e:
96
+ Log.error(f"PiWaveError: {e}")
97
+ sys.exit(1)
98
+
99
+ else:
100
+ Log.error(f"Unknown command: {cmd}")
101
+ sys.exit(1)
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
@@ -0,0 +1,126 @@
1
+ # PiWave is available at https://piwave.xyz
2
+ # Licensed under GPLv3.0, main GitHub repository at https://github.com/douxxtech/piwave/
3
+ # piwave/backends/__init__.py : some utility funcs for backends
4
+
5
+ from .pi_fm_rds import PiFmRdsBackend
6
+ from .fm_transmitter import FmTransmitterBackend
7
+ from ..logger import Log
8
+
9
+ # auto discover backends
10
+ backends = {}
11
+ backend_classes = {
12
+ "pi_fm_rds": PiFmRdsBackend,
13
+ "fm_transmitter": FmTransmitterBackend
14
+ }
15
+
16
+
17
+ def get_best_backend(mode: str, frequency: float):
18
+ if mode == "file_broadcast":
19
+ if 88.0 <= frequency <= 108.0:
20
+ if "pi_fm_rds" in backends:
21
+ return "pi_fm_rds"
22
+ elif "fm_transmitter" in backends:
23
+ return "fm_transmitter"
24
+ else:
25
+ if "fm_transmitter" in backends:
26
+ return "fm_transmitter"
27
+ elif "pi_fm_rds" in backends:
28
+ return "pi_fm_rds"
29
+
30
+ elif mode == "live_broadcast":
31
+ if "fm_transmitter" in backends:
32
+ backend = backends["fm_transmitter"]()
33
+ min_freq, max_freq = backend.frequency_range
34
+ if min_freq <= frequency <= max_freq:
35
+ return "fm_transmitter"
36
+
37
+ return None
38
+
39
+ return None
40
+
41
+ def discover_backends():
42
+ """
43
+ Populate `backends` dictionary with available backends.
44
+ Reads cache files first, probes hardware only if no cache exists.
45
+ Stores executable path in cache if available, "None" otherwise.
46
+ """
47
+ backends.clear()
48
+
49
+ for name, backend_class in backend_classes.items():
50
+ backend = backend_class()
51
+
52
+ if backend.cache_file.exists():
53
+ content = backend.cache_file.read_text().strip()
54
+ if content == "None":
55
+ available = False
56
+ else:
57
+ available = True
58
+ backend._executable_path = content
59
+ else:
60
+ try:
61
+ available = backend.is_available()
62
+ except Exception:
63
+ available = False
64
+
65
+ if available:
66
+ backend.cache_file.write_text(backend.required_executable)
67
+ else:
68
+ backend.cache_file.write_text("None")
69
+
70
+ if available:
71
+ backends[name] = backend_class
72
+
73
+ return backends
74
+
75
+
76
+ def search_backends():
77
+ """
78
+ Probe all backends directly, ignoring any cache.
79
+ Updates the cache files with the actual availability.
80
+ Overwrites existing cache files.
81
+ """
82
+ backends.clear()
83
+
84
+ for name, backend_class in backend_classes.items():
85
+ backend = backend_class()
86
+
87
+ try:
88
+ available = backend.is_available()
89
+ except Exception:
90
+ available = False
91
+
92
+ if available:
93
+ backend.cache_file.write_text(backend.required_executable)
94
+ backends[name] = backend_class
95
+ else:
96
+ backend.cache_file.write_text("None")
97
+
98
+ if backends:
99
+ Log.success(f"Found backends: {', '.join(backends.keys())}")
100
+ else:
101
+ Log.warning("No backends found. Please install pi_fm_rds or fm_transmitter")
102
+
103
+
104
+
105
+ def list_backends():
106
+ """List every cached backend avalible
107
+
108
+ .. note::
109
+ You can also use the 'python3 -m piwave list' command.
110
+ """
111
+ if not backends:
112
+ Log.warning("No backends available")
113
+ return {}
114
+
115
+ backend_info = {}
116
+ for name, backend_class in backends.items():
117
+ backend = backend_class()
118
+ min_freq, max_freq = backend.frequency_range
119
+ rds = "Yes" if backend.supports_rds else "No"
120
+ backend_info[name] = {
121
+ 'frequency_range': f"{min_freq}-{max_freq}MHz",
122
+ 'rds_support': rds
123
+ }
124
+ Log.info(f"{name}: {min_freq}-{max_freq}MHz, RDS: {rds}")
125
+
126
+ return backend_info
@@ -0,0 +1,245 @@
1
+ # PiWave is available at https://piwave.xyz
2
+ # Licensed under GPLv3.0, main GitHub repository at https://github.com/douxxtech/piwave/
3
+ # piwave/backend/base.py : backends backbone
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Optional
7
+ import subprocess
8
+ import os
9
+ import signal
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ class BackendError(Exception):
14
+ pass
15
+
16
+ class Backend(ABC):
17
+ def __init__(self, frequency: float = 90.0, ps: str = "PiWave",
18
+ rt: str = "", pi: str = "FFFF"):
19
+ self.frequency = frequency
20
+ self.ps = ps[:8] # Max 8 chars
21
+ self.rt = rt[:64] # Max 64 chars
22
+ self.pi = pi[:4] # Max 4 chars
23
+ self.current_process: Optional[subprocess.Popen] = None
24
+ self.is_available_cached: Optional[bool] = None
25
+ self._executable_path: Optional[str] = None
26
+
27
+ @property
28
+ @abstractmethod
29
+ def name(self):
30
+ pass
31
+
32
+ @property
33
+ @abstractmethod
34
+ def frequency_range(self):
35
+ pass
36
+
37
+ @property
38
+ @abstractmethod
39
+ def supports_rds(self):
40
+ pass
41
+
42
+ @property
43
+ @abstractmethod
44
+ def supports_live_streaming(self):
45
+ pass
46
+
47
+ @property
48
+ def cache_file(self):
49
+ return Path(__file__).parent.parent / f"{self.name}_path"
50
+
51
+ @property
52
+ def required_executable(self):
53
+ if self._executable_path:
54
+ return self._executable_path
55
+
56
+ self._executable_path = self._find_executable()
57
+ return self._executable_path
58
+
59
+ def _find_executable(self) -> str:
60
+ # Check cache first
61
+ if self.cache_file.exists():
62
+ try:
63
+ cached_path = self.cache_file.read_text().strip()
64
+ if self._is_valid_executable(cached_path):
65
+ return cached_path
66
+ else:
67
+ self.cache_file.unlink()
68
+ except Exception:
69
+ self.cache_file.unlink(missing_ok=True)
70
+
71
+ # Only then search for it
72
+ found_path = self._search_executable()
73
+ if found_path:
74
+ try:
75
+ self.cache_file.write_text(found_path)
76
+ except Exception:
77
+ pass
78
+ return found_path
79
+
80
+ raise BackendError(f"Could not find path for {self.name}. Please manually add one with python3 -m piwave add {self.name} <path>")
81
+
82
+ @abstractmethod
83
+ def _get_executable_name(self):
84
+ pass
85
+
86
+ @abstractmethod
87
+ def _get_search_paths(self):
88
+ pass
89
+
90
+ def _search_executable(self):
91
+ executable_name = self._get_executable_name()
92
+
93
+ # Try at first system $PATH
94
+ system_path = shutil.which(executable_name)
95
+ if system_path and self._is_valid_executable(system_path):
96
+ return system_path
97
+
98
+ # Then for the dirs
99
+ search_paths = self._get_search_paths()
100
+
101
+ for search_path in search_paths:
102
+ if not Path(search_path).exists():
103
+ continue
104
+
105
+ try:
106
+ for root, dirs, files in os.walk(search_path):
107
+ if executable_name in files:
108
+ executable_path = Path(root) / executable_name
109
+ if self._is_valid_executable(str(executable_path)):
110
+ return str(executable_path)
111
+ except (PermissionError, OSError):
112
+ continue
113
+
114
+ return None
115
+
116
+ # valid executable part start
117
+
118
+ def _is_valid_executable(self, path: str) -> bool:
119
+
120
+ path_obj = Path(path)
121
+ if not (path_obj.exists() and path_obj.is_file() and os.access(path, os.X_OK)):
122
+ return False
123
+
124
+ # test any approach, if only one is valid, its ok
125
+ validation_methods = [
126
+ self._ve_try_help_flag,
127
+ self._ve_try_version_flag,
128
+ self._ve_try_no_args,
129
+ self._ve_try_invalid_flag
130
+ ]
131
+
132
+ for method in validation_methods:
133
+ try:
134
+ if method(path):
135
+ return True
136
+ except Exception:
137
+ continue
138
+
139
+ return False
140
+
141
+ def _ve_try_help_flag(self, path: str) -> bool:
142
+ try:
143
+ result = subprocess.run(
144
+ [path, "--help"],
145
+ stdout=subprocess.PIPE,
146
+ stderr=subprocess.PIPE,
147
+ timeout=3
148
+ )
149
+
150
+ return result.returncode in [0, 1, 2]
151
+ except (subprocess.TimeoutExpired, FileNotFoundError):
152
+ return False
153
+
154
+ def _ve_try_version_flag(self, path: str) -> bool:
155
+ for flag in ["--version", "-v", "-V"]:
156
+ try:
157
+ result = subprocess.run(
158
+ [path, flag],
159
+ stdout=subprocess.PIPE,
160
+ stderr=subprocess.PIPE,
161
+ timeout=3
162
+ )
163
+ if result.returncode in [0, 1]:
164
+ return True
165
+ except (subprocess.TimeoutExpired, FileNotFoundError):
166
+ continue
167
+ return False
168
+
169
+ def _ve_try_no_args(self, path: str) -> bool:
170
+ try:
171
+ result = subprocess.run(
172
+ [path],
173
+ stdout=subprocess.PIPE,
174
+ stderr=subprocess.PIPE,
175
+ timeout=1
176
+ )
177
+ return True
178
+ except subprocess.TimeoutExpired:
179
+ return True
180
+ except (FileNotFoundError, PermissionError):
181
+ return False
182
+
183
+ def _ve_try_invalid_flag(self, path: str) -> bool:
184
+ try:
185
+ result = subprocess.run(
186
+ [path, "--piwave-test-invalid-flag"],
187
+ stdout=subprocess.PIPE,
188
+ stderr=subprocess.PIPE,
189
+ timeout=3
190
+ )
191
+ return result.returncode in [1, 2]
192
+ except (subprocess.TimeoutExpired, FileNotFoundError):
193
+ return False
194
+
195
+ # valid executable part end
196
+
197
+
198
+ def is_available(self):
199
+ if self.is_available_cached is not None:
200
+ return self.is_available_cached
201
+
202
+ try:
203
+ self.required_executable
204
+ self.is_available_cached = True
205
+ return True
206
+ except BackendError:
207
+ self.is_available_cached = False
208
+ return False
209
+
210
+ @abstractmethod
211
+ def build_command(self, wav_file: str):
212
+ pass
213
+
214
+ @abstractmethod
215
+ def build_live_command(self):
216
+ pass
217
+
218
+ def validate_settings(self):
219
+ min_freq, max_freq = self.frequency_range
220
+ return min_freq <= self.frequency <= max_freq
221
+
222
+ def play_file(self, wav_file: str) -> subprocess.Popen:
223
+ if not self.validate_settings():
224
+ min_freq, max_freq = self.frequency_range
225
+ raise BackendError(f"{self.name} supports {min_freq}-{max_freq}MHz, got {self.frequency}MHz")
226
+
227
+ cmd = self.build_command(wav_file)
228
+ self.current_process = subprocess.Popen(
229
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
230
+ preexec_fn=os.setsid
231
+ )
232
+ return self.current_process
233
+
234
+ def stop(self):
235
+ if self.current_process:
236
+ try:
237
+ os.killpg(os.getpgid(self.current_process.pid), signal.SIGTERM)
238
+ self.current_process.wait(timeout=5)
239
+ except (ProcessLookupError, subprocess.TimeoutExpired):
240
+ try:
241
+ os.killpg(os.getpgid(self.current_process.pid), signal.SIGKILL)
242
+ except ProcessLookupError:
243
+ pass
244
+ finally:
245
+ self.current_process = None
@@ -0,0 +1,44 @@
1
+ # PiWave is available at https://piwave.xyz
2
+ # Licensed under GPLv3.0, main GitHub repository at https://github.com/douxxtech/piwave/
3
+ # piwave/backends/fm_transmitter.py : fm_transmitter backend, doesn't supports RDS, can broadcast on freqs 1 to 250
4
+ # needs https://github.com/markondej/fm_transmitter installed on the system to work !
5
+
6
+ from .base import Backend
7
+
8
+ class FmTransmitterBackend(Backend):
9
+
10
+ @property
11
+ def name(self):
12
+ return "fm_transmitter"
13
+
14
+ @property
15
+ def frequency_range(self):
16
+ return (1.0, 250.0) # fm transmitter supports a pretty good range, tho be aware of performance issues
17
+
18
+ @property
19
+ def supports_rds(self):
20
+ return False
21
+
22
+ @property
23
+ def supports_live_streaming(self):
24
+ return True
25
+
26
+ def _get_executable_name(self):
27
+ return "fm_transmitter"
28
+
29
+ def _get_search_paths(self):
30
+ return ["/opt/PiWave/fm_transmitter", "/opt", "/usr/local/bin", "/usr/bin", "/bin", "/home"]
31
+
32
+ def build_command(self, wav_file: str):
33
+ return [
34
+ 'sudo', self.required_executable,
35
+ '-f', str(self.frequency),
36
+ wav_file
37
+ ]
38
+
39
+ def build_live_command(self):
40
+ return [
41
+ 'sudo', self.required_executable,
42
+ '-f', str(self.frequency),
43
+ '-'
44
+ ]
@@ -0,0 +1,49 @@
1
+ # PiWave is available at https://piwave.xyz
2
+ # Licensed under GPLv3.0, main GitHub repository at https://github.com/douxxtech/piwave/
3
+ # piwave/backends/pi_fm_rds.py : PiFmRds backend, supports RDS on freqs 80 to 108
4
+ # needs https://github.com/ChristopheJacquet/PiFmRds installed on the system to work !
5
+
6
+ from .base import Backend, BackendError
7
+
8
+ class PiFmRdsBackend(Backend):
9
+
10
+ @property
11
+ def name(self):
12
+ return "pi_fm_rds"
13
+
14
+ @property
15
+ def frequency_range(self):
16
+ return (80.0, 108.0) # standard fm band with a bit less bcs it still kinda works
17
+
18
+ @property
19
+ def supports_rds(self):
20
+ return True
21
+
22
+ @property
23
+ def supports_live_streaming(self):
24
+ return False
25
+
26
+ def _get_executable_name(self):
27
+ return "pi_fm_rds"
28
+
29
+ def _get_search_paths(self):
30
+ return ["/opt/PiWave/PiFmRds", "/opt", "/usr/local/bin", "/usr/bin", "/bin", "/home"]
31
+
32
+ def build_command(self, wav_file: str) -> list:
33
+ cmd = [
34
+ 'sudo', self.required_executable,
35
+ '-freq', str(self.frequency),
36
+ '-audio', wav_file
37
+ ]
38
+
39
+ if self.ps:
40
+ cmd.extend(['-ps', self.ps])
41
+ if self.rt:
42
+ cmd.extend(['-rt', self.rt])
43
+ if self.pi:
44
+ cmd.extend(['-pi', self.pi])
45
+
46
+ return cmd
47
+
48
+ def build_live_command(self):
49
+ return None # not supported sadly
piwave/logger.py ADDED
@@ -0,0 +1,99 @@
1
+ # PiWave is available at https://piwave.xyz
2
+ # Licensed under GPLv3.0, main GitHub repository at https://github.com/douxxtech/piwave/
3
+ # piwave/Logger.py : Main logging manager
4
+
5
+ import sys
6
+
7
+ class Log:
8
+ COLORS = { # absolutely not taken from stackoverflow trust
9
+ 'reset': '\033[0m',
10
+ 'bold': '\033[1m',
11
+ 'underline': '\033[4m',
12
+ 'red': '\033[31m',
13
+ 'green': '\033[32m',
14
+ 'yellow': '\033[33m',
15
+ 'blue': '\033[34m',
16
+ 'magenta': '\033[35m',
17
+ 'cyan': '\033[36m',
18
+ 'white': '\033[37m',
19
+ 'bright_red': '\033[91m',
20
+ 'bright_green': '\033[92m',
21
+ 'bright_yellow': '\033[93m',
22
+ 'bright_blue': '\033[94m',
23
+ 'bright_magenta': '\033[95m',
24
+ 'bright_cyan': '\033[96m',
25
+ 'bright_white': '\033[97m',
26
+ }
27
+
28
+ ICONS = {
29
+ 'success': 'OK',
30
+ 'error': 'ERR',
31
+ 'warning': 'WARN',
32
+ 'info': 'INFO',
33
+ 'client': 'CLIENT',
34
+ 'server': 'SERVER',
35
+ 'file': 'FILE',
36
+ 'broadcast': 'BCAST',
37
+ 'version': 'VER',
38
+ 'update': 'UPD',
39
+ }
40
+
41
+ SILENT = False
42
+
43
+ @classmethod
44
+ def config(cls, silent: bool = False):
45
+ cls.SILENT = silent
46
+
47
+ @classmethod
48
+ def print(cls, message: str, style: str = '', icon: str = '', end: str = '\n'):
49
+
50
+ if cls.SILENT: return
51
+
52
+ color = cls.COLORS.get(style, '')
53
+ icon_char = cls.ICONS.get(icon, '')
54
+ if icon_char:
55
+ if color:
56
+ print(f"{color}[{icon_char}]\033[0m {message}", end=end)
57
+ else:
58
+ print(f"[{icon_char}] {message}", end=end)
59
+ else:
60
+ if color:
61
+ print(f"{color}{message}\033[0m", end=end)
62
+ else:
63
+ print(f"{message}", end=end)
64
+ sys.stdout.flush()
65
+
66
+ @classmethod
67
+ def header(cls, text: str):
68
+ cls.print(text, 'bright_blue', end='\n\n')
69
+ sys.stdout.flush()
70
+
71
+ @classmethod
72
+ def section(cls, text: str):
73
+ cls.print(f" {text} ", 'bright_blue', end='')
74
+ cls.print("─" * (len(text) + 2), 'blue', end='\n\n')
75
+ sys.stdout.flush()
76
+
77
+ @classmethod
78
+ def success(cls, message: str):
79
+ cls.print(message, 'bright_green', 'success')
80
+
81
+ @classmethod
82
+ def error(cls, message: str):
83
+ cls.print(message, 'bright_red', 'error')
84
+
85
+ @classmethod
86
+ def warning(cls, message: str):
87
+ cls.print(message, 'bright_yellow', 'warning')
88
+
89
+ @classmethod
90
+ def info(cls, message: str):
91
+ cls.print(message, 'bright_cyan', 'info')
92
+
93
+ @classmethod
94
+ def file_message(cls, message: str):
95
+ cls.print(message, 'yellow', 'file')
96
+
97
+ @classmethod
98
+ def broadcast_message(cls, message: str):
99
+ cls.print(message, 'bright_magenta', 'broadcast')