piwave 2.0.9__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 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,119 @@
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
+
25
+ else:
26
+ if "fm_transmitter" in backends:
27
+ return "fm_transmitter"
28
+ elif "pi_fm_rds" in backends:
29
+ return "pi_fm_rds"
30
+
31
+
32
+ return None
33
+
34
+ def discover_backends():
35
+ """
36
+ Populate `backends` dictionary with available backends.
37
+ Reads cache files first, probes hardware only if no cache exists.
38
+ Stores executable path in cache if available, "None" otherwise.
39
+ """
40
+ backends.clear()
41
+
42
+ for name, backend_class in backend_classes.items():
43
+ backend = backend_class()
44
+
45
+ if backend.cache_file.exists():
46
+ content = backend.cache_file.read_text().strip()
47
+ if content == "None":
48
+ available = False
49
+ else:
50
+ available = True
51
+ backend._executable_path = content
52
+ else:
53
+ try:
54
+ available = backend.is_available()
55
+ except Exception:
56
+ available = False
57
+
58
+ if available:
59
+ backend.cache_file.write_text(backend.required_executable)
60
+ else:
61
+ backend.cache_file.write_text("None")
62
+
63
+ if available:
64
+ backends[name] = backend_class
65
+
66
+ return backends
67
+
68
+
69
+ def search_backends():
70
+ """
71
+ Probe all backends directly, ignoring any cache.
72
+ Updates the cache files with the actual availability.
73
+ Overwrites existing cache files.
74
+ """
75
+ backends.clear()
76
+
77
+ for name, backend_class in backend_classes.items():
78
+ backend = backend_class()
79
+
80
+ try:
81
+ available = backend.is_available()
82
+ except Exception:
83
+ available = False
84
+
85
+ if available:
86
+ backend.cache_file.write_text(backend.required_executable)
87
+ backends[name] = backend_class
88
+ else:
89
+ backend.cache_file.write_text("None")
90
+
91
+ if backends:
92
+ Log.success(f"Found backends: {', '.join(backends.keys())}")
93
+ else:
94
+ Log.warning("No backends found. Please install pi_fm_rds or fm_transmitter")
95
+
96
+
97
+
98
+ def list_backends():
99
+ """List every cached backend avalible
100
+
101
+ .. note::
102
+ You can also use the 'python3 -m piwave list' command.
103
+ """
104
+ if not backends:
105
+ Log.warning("No backends available")
106
+ return {}
107
+
108
+ backend_info = {}
109
+ for name, backend_class in backends.items():
110
+ backend = backend_class()
111
+ min_freq, max_freq = backend.frequency_range
112
+ rds = "Yes" if backend.supports_rds else "No"
113
+ backend_info[name] = {
114
+ 'frequency_range': f"{min_freq}-{max_freq}MHz",
115
+ 'rds_support': rds
116
+ }
117
+ Log.info(f"{name}: {min_freq}-{max_freq}MHz, RDS: {rds}")
118
+
119
+ return backend_info
@@ -0,0 +1,236 @@
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
+ def cache_file(self):
44
+ return Path(__file__).parent.parent / f"{self.name}_path"
45
+
46
+ @property
47
+ def required_executable(self):
48
+ if self._executable_path:
49
+ return self._executable_path
50
+
51
+ self._executable_path = self._find_executable()
52
+ return self._executable_path
53
+
54
+ def _find_executable(self) -> str:
55
+ # Check cache first
56
+ if self.cache_file.exists():
57
+ try:
58
+ cached_path = self.cache_file.read_text().strip()
59
+ if self._is_valid_executable(cached_path):
60
+ return cached_path
61
+ else:
62
+ self.cache_file.unlink()
63
+ except Exception:
64
+ self.cache_file.unlink(missing_ok=True)
65
+
66
+ # Only then search for it
67
+ found_path = self._search_executable()
68
+ if found_path:
69
+ try:
70
+ self.cache_file.write_text(found_path)
71
+ except Exception:
72
+ pass
73
+ return found_path
74
+
75
+ raise BackendError(f"Could not find path for {self.name}. Please manually add one with python3 -m piwave add {self.name} <path>")
76
+
77
+ @abstractmethod
78
+ def _get_executable_name(self):
79
+ pass
80
+
81
+ @abstractmethod
82
+ def _get_search_paths(self):
83
+ pass
84
+
85
+ def _search_executable(self):
86
+ executable_name = self._get_executable_name()
87
+
88
+ # Try at first system $PATH
89
+ system_path = shutil.which(executable_name)
90
+ if system_path and self._is_valid_executable(system_path):
91
+ return system_path
92
+
93
+ # Then for the dirs
94
+ search_paths = self._get_search_paths()
95
+
96
+ for search_path in search_paths:
97
+ if not Path(search_path).exists():
98
+ continue
99
+
100
+ try:
101
+ for root, dirs, files in os.walk(search_path):
102
+ if executable_name in files:
103
+ executable_path = Path(root) / executable_name
104
+ if self._is_valid_executable(str(executable_path)):
105
+ return str(executable_path)
106
+ except (PermissionError, OSError):
107
+ continue
108
+
109
+ return None
110
+
111
+ # valid executable part start
112
+
113
+ def _is_valid_executable(self, path: str) -> bool:
114
+
115
+ path_obj = Path(path)
116
+ if not (path_obj.exists() and path_obj.is_file() and os.access(path, os.X_OK)):
117
+ return False
118
+
119
+ # test any approach, if only one is valid, its ok
120
+ validation_methods = [
121
+ self._ve_try_help_flag,
122
+ self._ve_try_version_flag,
123
+ self._ve_try_no_args,
124
+ self._ve_try_invalid_flag
125
+ ]
126
+
127
+ for method in validation_methods:
128
+ try:
129
+ if method(path):
130
+ return True
131
+ except Exception:
132
+ continue
133
+
134
+ return False
135
+
136
+ def _ve_try_help_flag(self, path: str) -> bool:
137
+ try:
138
+ result = subprocess.run(
139
+ [path, "--help"],
140
+ stdout=subprocess.PIPE,
141
+ stderr=subprocess.PIPE,
142
+ timeout=3
143
+ )
144
+
145
+ return result.returncode in [0, 1, 2]
146
+ except (subprocess.TimeoutExpired, FileNotFoundError):
147
+ return False
148
+
149
+ def _ve_try_version_flag(self, path: str) -> bool:
150
+ for flag in ["--version", "-v", "-V"]:
151
+ try:
152
+ result = subprocess.run(
153
+ [path, flag],
154
+ stdout=subprocess.PIPE,
155
+ stderr=subprocess.PIPE,
156
+ timeout=3
157
+ )
158
+ if result.returncode in [0, 1]:
159
+ return True
160
+ except (subprocess.TimeoutExpired, FileNotFoundError):
161
+ continue
162
+ return False
163
+
164
+ def _ve_try_no_args(self, path: str) -> bool:
165
+ try:
166
+ result = subprocess.run(
167
+ [path],
168
+ stdout=subprocess.PIPE,
169
+ stderr=subprocess.PIPE,
170
+ timeout=1
171
+ )
172
+ return True
173
+ except subprocess.TimeoutExpired:
174
+ return True
175
+ except (FileNotFoundError, PermissionError):
176
+ return False
177
+
178
+ def _ve_try_invalid_flag(self, path: str) -> bool:
179
+ try:
180
+ result = subprocess.run(
181
+ [path, "--piwave-test-invalid-flag"],
182
+ stdout=subprocess.PIPE,
183
+ stderr=subprocess.PIPE,
184
+ timeout=3
185
+ )
186
+ return result.returncode in [1, 2]
187
+ except (subprocess.TimeoutExpired, FileNotFoundError):
188
+ return False
189
+
190
+ # valid executable part end
191
+
192
+
193
+ def is_available(self):
194
+ if self.is_available_cached is not None:
195
+ return self.is_available_cached
196
+
197
+ try:
198
+ self.required_executable
199
+ self.is_available_cached = True
200
+ return True
201
+ except BackendError:
202
+ self.is_available_cached = False
203
+ return False
204
+
205
+ @abstractmethod
206
+ def build_command(self, wav_file: str):
207
+ pass
208
+
209
+ def validate_settings(self):
210
+ min_freq, max_freq = self.frequency_range
211
+ return min_freq <= self.frequency <= max_freq
212
+
213
+ def play_file(self, wav_file: str) -> subprocess.Popen:
214
+ if not self.validate_settings():
215
+ min_freq, max_freq = self.frequency_range
216
+ raise BackendError(f"{self.name} supports {min_freq}-{max_freq}MHz, got {self.frequency}MHz")
217
+
218
+ cmd = self.build_command(wav_file)
219
+ self.current_process = subprocess.Popen(
220
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
221
+ preexec_fn=os.setsid
222
+ )
223
+ return self.current_process
224
+
225
+ def stop(self):
226
+ if self.current_process:
227
+ try:
228
+ os.killpg(os.getpgid(self.current_process.pid), signal.SIGTERM)
229
+ self.current_process.wait(timeout=5)
230
+ except (ProcessLookupError, subprocess.TimeoutExpired):
231
+ try:
232
+ os.killpg(os.getpgid(self.current_process.pid), signal.SIGKILL)
233
+ except ProcessLookupError:
234
+ pass
235
+ finally:
236
+ self.current_process = None
@@ -0,0 +1,33 @@
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
+ def _get_executable_name(self):
23
+ return "fm_transmitter"
24
+
25
+ def _get_search_paths(self):
26
+ return ["/opt/PiWave/fm_transmitter", "/opt", "/usr/local/bin", "/usr/bin", "/bin", "/home", "/home/pi"]
27
+
28
+ def build_command(self, wav_file: str):
29
+ return [
30
+ 'sudo', self.required_executable,
31
+ '-f', str(self.frequency),
32
+ wav_file
33
+ ]
@@ -0,0 +1,42 @@
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
+ def _get_executable_name(self):
23
+ return "pi_fm_rds"
24
+
25
+ def _get_search_paths(self):
26
+ return ["/opt/PiWave/PiFmRds", "/opt", "/usr/local/bin", "/usr/bin", "/bin", "/home"]
27
+
28
+ def build_command(self, wav_file: str) -> list:
29
+ cmd = [
30
+ 'sudo', self.required_executable,
31
+ '-freq', str(self.frequency),
32
+ '-audio', wav_file
33
+ ]
34
+
35
+ if self.ps:
36
+ cmd.extend(['-ps', self.ps])
37
+ if self.rt:
38
+ cmd.extend(['-rt', self.rt])
39
+ if self.pi:
40
+ cmd.extend(['-pi', self.pi])
41
+
42
+ return cmd
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')