BOSWatch 3
Python Script to receive and decode German BOS Information with rtl_fm and multimon-NG
 
Loading...
Searching...
No Matches
manager.BW3Manager Class Reference

Public Member Functions

 __init__ (self)
 
 load_config (self)
 
 save_config (self)
 
 load_translations (self)
 
 t (self, key)
 
 run_with_progress (self, commands, desc)
 
 log (self, message, color=Fore.WHITE, bright=False)
 
 setup_example_configs (self)
 
 restart_active_services (self)
 
 safe_venv_cleanup (self)
 
 change_branch_logic (self)
 
 get_service_dashboard_data (self)
 
 show_service_logs (self)
 
 main_menu (self)
 
 wait_for_apt (self, timeout=300)
 
 install_routine (self)
 
 update_routine (self)
 
 compile_rtlsdr (self, commit)
 
 compile_multimon (self, branch)
 
 check_and_update_hardware_tools (self)
 
 ask_for_reboot (self)
 
 service_menu (self)
 
 install_single_service (self, config_file)
 
 remove_single_service (self, service_name)
 
 get_required_packages (self)
 
 sync_dependencies (self, silent=False)
 
 parse_requirements_tags (self)
 

Data Fields

 base_path
 
 install_path
 
 real_user
 
 config_path
 
 trans_path
 
 defaults
 
 lang
 
 config
 
 translations
 

Protected Member Functions

 _check_config_drift (self, example_file, real_file)
 
 _sync_core_requirements (self)
 
 _update_pip_tracking (self)
 

Constructor & Destructor Documentation

◆ __init__()

manager.BW3Manager.__init__ (   self)
36 def __init__(self):
37 # Central path to project root
38 self.base_path = Path(__file__).parent.parent.resolve()
39 self.install_path = Path(__file__).parent.resolve()
40
41 # SUDO_USER detection
42 self.real_user = os.environ.get('SUDO_USER') or os.getlogin()
43
44 self.config_path = self.install_path / "manager_config.yaml"
45 self.trans_path = self.install_path / "translations.yaml"
46
47 # ============== CENTRAL DEFINITION ==============
48 self.defaults = {
49 'repo_url': "https://github.com/BOSWatch/BW3-Core",
50 'branch': "master",
51 'language': "de",
52 'installed': False,
53 'rtlsdr_commit': "2659e2df31e592d74d6dd264a4f5ce242c6369c8",
54 'multimon_branch': "1.1.8",
55 'installed_rtlsdr': None,
56 'installed_multimon': None,
57 'installed_packages': {} # ← will be filled dynamically
58 }
59
60 self.load_config()
61 self.load_translations()
62 self.lang = self.config['language']
63

Member Function Documentation

◆ load_config()

manager.BW3Manager.load_config (   self)
Loads the config and fills missing values with defaults.
66 def load_config(self):
67 """Loads the config and fills missing values with defaults."""
68 if self.config_path.exists():
69 with open(self.config_path, 'r') as f:
70 loaded = yaml.safe_load(f) or {}
71 self.config = {**self.defaults, **loaded}
72 else:
73 self.config = self.defaults.copy()
74 self.save_config()
75

◆ save_config()

manager.BW3Manager.save_config (   self)
Saves the current state of the config with fixed sorting and manual change protection.
76 def save_config(self):
77 """Saves the current state of the config with fixed sorting and manual change protection."""
78 header = r"""# -*- coding: utf-8 -*-
79# ____ ____ ______ __ __ __ _____
80# / __ )/ __ \/ ___/ | / /___ _/ /______/ /_ |__ /
81# / __ / / / /\__ \| | /| / / __ `/ __/ ___/ __ \ /_ <
82# / /_/ / /_/ /___/ /| |/ |/ / /_/ / /_/ /__/ / / / ___/ /
83#/_____/\____//____/ |__/|__/\__,_/\__/\___/_/ /_/ /____/
84# German BOS Information Script
85# by Bastian Schroll
86"""
87 # 1. Get clean copy of disk to preserve manual edits
88 disk_config = {}
89 if self.config_path.exists():
90 try:
91 with open(self.config_path, 'r', encoding='utf-8') as f:
92 disk_config = yaml.safe_load(f) or {}
93 except Exception:
94 pass
95
96 # 2. Merge actual changes from program into disk state
97 disk_config.update(self.config)
98 self.config = disk_config
99
100 # 3. Defining fixed order
101 order = [
102 'installed', 'version', 'branch',
103 'installed_multimon', 'multimon_branch',
104 'installed_rtlsdr', 'rtlsdr_commit',
105 'installed_packages'
106 ]
107
108 # 4. Rebuilding dict in correct order
109 sorted_config = {}
110 for key in order:
111 if key in self.config:
112 sorted_config[key] = self.config[key]
113
114 # Add all other keys (like repo_url, etc.)
115 for key in self.config:
116 if key not in sorted_config:
117 sorted_config[key] = self.config[key]
118
119 # 5. Save with sort_keys=False
120 with open(self.config_path, 'w', encoding='utf-8') as f:
121 f.write(header + "\n")
122 yaml.dump(sorted_config, f, default_flow_style=False, sort_keys=False)
123

◆ load_translations()

manager.BW3Manager.load_translations (   self)
Loads translation strings from YAML file.
124 def load_translations(self):
125 """Loads translation strings from YAML file."""
126 with open(self.trans_path, 'r') as f:
127 self.translations = yaml.safe_load(f)
128

◆ t()

manager.BW3Manager.t (   self,
  key 
)
Gets the translation for the key. Returns the key itself if not found.
129 def t(self, key):
130 """Gets the translation for the key. Returns the key itself if not found."""
131 lang = self.config.get('language', 'de')
132
133 # If language is missing in YAML, fallback to German
134 if lang not in self.translations:
135 lang = 'de'
136
137 # Use .get() to return a default value (the key itself) if missing.
138 return self.translations[lang].get(key, f"[{key}]")
139

◆ run_with_progress()

manager.BW3Manager.run_with_progress (   self,
  commands,
  desc 
)
Executes a list of commands with a progress bar and writes everything to a log file.
140 def run_with_progress(self, commands, desc):
141 """Executes a list of commands with a progress bar and writes everything to a log file."""
142 log_dir = self.base_path / "log" / "install"
143 log_dir.mkdir(parents=True, exist_ok=True) # Ensure folder exists
144 log_file = log_dir / "setup_log.txt"
145
146 # Open log in 'a' (Append) mode
147 with open(log_file, "a") as f:
148 f.write(f"\n--- Starting Task: {desc} ---\n")
149
150 with alive_bar(len(commands), title=desc, bar='smooth', spinner='dots') as bar:
151 for cmd in commands:
152 f.write(f"Executing: {cmd}\n")
153 result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
154
155 if result.stdout:
156 f.write(result.stdout)
157 if result.stderr:
158 prefix = ("STDERR" if result.returncode != 0 else "INFO/STDERR")
159 f.write(f"{prefix}: {result.stderr}")
160
161 if result.returncode != 0:
162 f.write(f"FAILED with return code {result.returncode}\n")
163 self.log(Fore.RED + self.t('run_cmd_failed').format(cmd, log_file))
164 raise subprocess.CalledProcessError(result.returncode, cmd)
165
166 bar() # ← step forward
167

◆ log()

manager.BW3Manager.log (   self,
  message,
  color = Fore.WHITE,
  bright = False 
)
Prints in color to the terminal and cleanly to the logfile.
170 def log(self, message, color=Fore.WHITE, bright=False):
171 """Prints in color to the terminal and cleanly to the logfile."""
172 # 1. Terminal Output (Colored)
173 style = Style.BRIGHT if bright else Style.NORMAL
174 print(f"{color}{style}{message}{Style.RESET_ALL}")
175
176 # 2. Log File Output (Plain text)
177 log_file = self.base_path / "log" / "install" / "setup_log.txt"
178 with open(log_file, "a") as f:
179 f.write(f"[{time.strftime('%H:%M:%S')}] {message}\n")
180

◆ setup_example_configs()

manager.BW3Manager.setup_example_configs (   self)
Copies .example files from config/examples/ to real config files.
183 def setup_example_configs(self):
184 """Copies .example files from config/examples/ to real config files."""
185 self.log(f"\n{Fore.CYAN}--- {self.t('check_configs_header')} ---")
186 config_dir = self.base_path / "config"
187 examples_dir = config_dir / "examples"
188
189 if not examples_dir.exists():
190 self.log(Fore.YELLOW + self.t('config_no_examples_dir'))
191 return
192
193 # Find all .example files
194 for example_file in examples_dir.iterdir():
195 # Only consider .yaml and .ini files
196 if example_file.suffix not in ('.yaml', '.ini'):
197 continue
198
199 real_file = config_dir / example_file.name
200
201 if not real_file.exists():
202 self.log(Fore.GREEN + self.t('config_standard').format(real_file.name))
203 shutil.copy(example_file, real_file)
204 try:
205 # Correct owner (so normal user can edit them)
206 uid = pwd.getpwnam(self.real_user).pw_uid
207 gid = pwd.getpwnam('www-data').pw_gid # or another group
208 os.chown(real_file, uid, gid)
209 # Set permissions so user can edit them
210 os.chmod(real_file, 0o664)
211 except KeyError:
212 # www-data might not exist on all systems
213 pass
214 self.log(Fore.GREEN + self.t('config_created').format(real_file.name))
215 else:
216 # Already exists — check for new keys (only prints on drift)
217 self._check_config_drift(example_file, real_file)
218 self.log(Fore.YELLOW + self.t('config_exists').format(real_file.name))
219

◆ _check_config_drift()

manager.BW3Manager._check_config_drift (   self,
  example_file,
  real_file 
)
protected
Warns if example file contains keys missing in user config.
220 def _check_config_drift(self, example_file, real_file):
221 """Warns if example file contains keys missing in user config."""
222 # Only useful for YAML, skip .ini
223 if example_file.suffix != '.yaml':
224 return
225 try:
226 with open(example_file) as f:
227 example_keys = set(yaml.safe_load(f) or {})
228 with open(real_file) as f:
229 real_keys = set(yaml.safe_load(f) or {})
230
231 new_keys = example_keys - real_keys
232 if new_keys:
233 self.log(
234 Fore.YELLOW +
235 f" ⚠ {real_file.name}: "
236 f"{self.t('config_new_keys').format(', '.join(sorted(new_keys)))}")
237 self.log(
238 Fore.YELLOW +
239 f" → {self.t('config_check_examples')}:"
240 f" config/examples/{example_file.name}")
241 except Exception:
242 pass
243

◆ restart_active_services()

manager.BW3Manager.restart_active_services (   self)
Finds all BOSWatch services and restarts them if active.
246 def restart_active_services(self):
247 """Finds all BOSWatch services and restarts them if active."""
248 self.log(f"\n{Fore.CYAN}--- {self.t('update_restarting_services')} ---")
249 service_dir = "/etc/systemd/system"
250 if not os.path.exists(service_dir):
251 return
252
253 # Find all bw3_*.service files
254 services = [f for f in os.listdir(service_dir) if f.startswith('bw3_') and f.endswith('.service')]
255
256 if not services:
257 self.log(f"{self.t('srv_none_found')}")
258 return
259
260 restarted = 0
261 for service in services:
262 # Check if service is currently running
263 status = subprocess.run(["systemctl", "is-active", "--quiet", service])
264 if status.returncode == 0: # 0 means 'active'
265 subprocess.run(["systemctl", "restart", service])
266 self.log(Fore.GREEN + self.t('srv_restarted').format(service))
267 restarted += 1
268 else:
269 self.log(Fore.YELLOW + self.t('srv_skip_inactive').format(service))
270
271 if restarted > 0:
272 # Daemon reload never hurts after update in case paths changed
273 subprocess.run(["systemctl", "daemon-reload"])
274

◆ safe_venv_cleanup()

manager.BW3Manager.safe_venv_cleanup (   self)
Entfernt Pakete die weder core/manager noch aktiv genutzt sind.
277 def safe_venv_cleanup(self):
278 """Entfernt Pakete die weder core/manager noch aktiv genutzt sind."""
279 self.log(f"\n{Fore.CYAN}{self.t('venv_safe_header')}")
280
281 venv_pip = self.base_path / "venv" / "bin" / "pip"
282 req_file = self.base_path / "requirements-runtime.txt"
283
284 if not req_file.exists():
285 self.log(Fore.YELLOW + self.t('venv_safe_no_req'))
286 return
287
288 tag_mapping = self.parse_requirements_tags()
289
290 # 1. Pakete die IMMER behalten werden: core + manager
291 always_keep = set(
292 p.split('==')[0].split('>=')[0].lower().replace('-', '_').strip()
293 for p in tag_mapping.get('core', []) + tag_mapping.get('manager', [])
294 )
295
296 # 2. Pakete die aktiv genutzt werden (aus server/client.yaml)
297 active_resources = self.get_required_packages()
298 actively_needed = set()
299
300 for resource in active_resources:
301 resource_lower = resource.lower()
302 # Exakter Match
303 if resource_lower in tag_mapping:
304 for pkg in tag_mapping[resource_lower]:
305 pkg_name = pkg.split('==')[0].split('>=')[0].lower().replace('-', '_').strip()
306 actively_needed.add(pkg_name)
307 # Teil-Match nach Punkt (z.B. modeFilter -> modefilter)
308 if '.' in resource_lower:
309 sub = resource_lower.split('.')[-1]
310 if sub in tag_mapping:
311 for pkg in tag_mapping[sub]:
312 pkg_name = pkg.split('==')[0].split('>=')[0].lower().replace('-', '_').strip()
313 actively_needed.add(pkg_name)
314
315 # 3. Gesamtmenge der erlaubten Pakete
316 allowed = always_keep | actively_needed
317
318 # System-Pakete niemals anfassen
319 critical = {'pip', 'setuptools', 'wheel', 'pkg_resources'}
320 allowed |= critical
321
322 # 4. Installierte Pakete abfragen
323 result = subprocess.run(
324 [str(venv_pip), "list", "--format=json"],
325 capture_output=True, text=True
326 )
327 installed_packages = [
328 pkg['name'].lower().replace('-', '_')
329 for pkg in json.loads(result.stdout)
330 ]
331
332 # 5. Kandidaten für Entfernung bestimmen
333 to_remove = []
334 for pkg in installed_packages:
335 if pkg in allowed:
336 continue
337
338 # Prüfen ob Paket von einem anderen benötigt wird (Transitive Dependency)
339 show_result = subprocess.run(
340 [str(venv_pip), "show", pkg],
341 capture_output=True, text=True
342 )
343 is_required = False
344 for line in show_result.stdout.splitlines():
345 if line.startswith("Required-by:"):
346 if line.split(":")[1].strip():
347 is_required = True
348 break
349
350 if not is_required:
351 to_remove.append(pkg)
352
353 # 6. Entfernen
354 if to_remove:
355 self.log(Fore.YELLOW + self.t('venv_safe_remove').format(to_remove))
356 for pkg in to_remove:
357 subprocess.run(
358 [str(venv_pip), "uninstall", "-y", pkg],
359 capture_output=True
360 )
361 self.log(Fore.GREEN + self.t('venv_safe_removed').format(len(to_remove), ', '.join(to_remove)))
362
363 else:
364 self.log(Fore.GREEN + self.t('venv_safe_clean'))
365

◆ change_branch_logic()

manager.BW3Manager.change_branch_logic (   self)
Logic for changing git branch.
368 def change_branch_logic(self):
369 """Logic for changing git branch."""
370 self.log(f"\n{Fore.YELLOW}" + self.t('branch_loading').format(self.config['repo_url']))
371 try:
372 result = subprocess.run(
373 f"git ls-remote --heads {self.config['repo_url']}",
374 shell=True, capture_output=True, text=True, timeout=10)
375
376 branches = []
377 for line in result.stdout.splitlines():
378 branch_name = line.split("refs/heads/")[-1]
379 branches.append(branch_name)
380
381 if not branches:
382 self.log(Fore.RED + self.t('branch_not_found'))
383 return
384
385 self.log(f"\n{self.t('select_option')}")
386 for i, b in enumerate(branches):
387 self.log(f"[{i + 1}] {b}")
388
389 choice_idx = int(input(self.t('branch_select_prompt'))) - 1
390 new_branch = branches[choice_idx]
391
392 self.config['branch'] = new_branch
393 # Save choice in manager_config.yaml
394 with open(self.config_path, 'w') as f:
395 yaml.dump(self.config, f)
396
397 self.log("\n" + Fore.GREEN + self.t('branch_set_success').format(new_branch))
398
399 # Convenience check
400 answer = input("\n" + self.t('branch_ask_update').format(new_branch))
401 if answer.lower() == 'y':
402 self.update_routine()
403 else:
404 self.log(Fore.YELLOW + self.t('branch_remembered'))
405
406 except Exception as e:
407 self.log(Fore.RED + self.t('branch_error').format(e))
408

◆ get_service_dashboard_data()

manager.BW3Manager.get_service_dashboard_data (   self)
Collects live data of installed BOSWatch services from systemd.
411 def get_service_dashboard_data(self):
412 """Collects live data of installed BOSWatch services from systemd."""
413 service_dir = "/etc/systemd/system"
414 if not os.path.exists(service_dir):
415 return {}
416
417 # Find all installed bw3_ services
418 services = [f for f in os.listdir(service_dir) if f.startswith('bw3_') and f.endswith('.service')]
419 if not services:
420 return {}
421
422 dashboard_data = {}
423 for svc in sorted(services):
424 # Name without .service for cleaner display
425 display_name = svc.replace('.service', '')
426
427 # systemctl show returns exact Key=Value pairs
428 res = subprocess.run(["systemctl", "show", svc, "--property=ActiveState,SubState,Result"], capture_output=True, text=True)
429
430 # Convert output to dictionary
431 props = dict(line.split('=', 1) for line in res.stdout.splitlines() if '=' in line)
432 dashboard_data[display_name] = props
433
434 return dashboard_data
435

◆ show_service_logs()

manager.BW3Manager.show_service_logs (   self)
Allows selection of a service and shows journalctl logs.
436 def show_service_logs(self):
437 """Allows selection of a service and shows journalctl logs."""
438 service_dir = Path("/etc/systemd/system")
439 services = sorted([f.name for f in service_dir.glob('bw3_*.service')])
440
441 if not services:
442 self.log(Fore.YELLOW + self.t('log_none'))
443 time.sleep(2)
444 return
445
446 while True:
447 # "Push" screen up for clean view
448 print("\n" * shutil.get_terminal_size().lines)
449 self.log(f"{Fore.GREEN}=== {self.t('menu_logs')} ===\n")
450
451 for i, svc in enumerate(services):
452 # Check if service currently has a problem
453 status = subprocess.run(["systemctl", "is-failed", svc], capture_output=True, text=True).stdout.strip()
454 error_hint = Fore.RED + " [FAILED]" if status == "failed" else ""
455 self.log(f"[{i + 1}] {svc}{error_hint}")
456
457 self.log("[0] " + self.t('menu_exit'))
458
459 choice = input("\n" + self.t('log_select_prompt'))
460
461 if choice == "0":
462 break
463
464 try:
465 idx = int(choice) - 1
466 if 0 <= idx < len(services):
467 selected_svc = services[idx]
468 self.log(f"\n{Fore.CYAN}" + self.t('log_viewing').format(selected_svc))
469
470 # journalctl: -u (unit), -n 50 (last 50), --no-pager
471 subprocess.run(["journalctl", "-u", selected_svc, "-n", "50", "--no-pager"])
472
473 input(Fore.YELLOW + self.t('log_back_prompt'))
474 except (ValueError, IndexError):
475 continue
476

◆ main_menu()

manager.BW3Manager.main_menu (   self)
Main interaction menu.
479 def main_menu(self):
480 """Main interaction menu."""
481 if os.geteuid() != 0:
482 self.log(Fore.RED + self.t('error_not_root'))
483 return
484
485 while True:
486 # --- GENTLE SCREEN RESET WITH TOP-ALIGNMENT ---
487 # terminal_height = shutil.get_terminal_size().lines
488 # Push up and set cursor to top with \033[H
489 # print("\n" * terminal_height + "\033[H", end="")
490
491 width = shutil.get_terminal_size().columns
492 timestamp = time.strftime('%H:%M:%S')
493 separator = f"─── {timestamp} " + "─" * (width - len(timestamp) - 5)
494 print(f"\n{Style.DIM}{separator}{Style.RESET_ALL}")
495
496 is_installed = self.config.get('installed', False)
497
498 # Header
499 self.log(f"\n{Fore.GREEN}=== {self.t('welcome')} ===")
500
501 # --- DASHBOARD BLOCK START ---
502 if is_installed:
503 services_status = self.get_service_dashboard_data()
504 if services_status:
505 self.log(f"{Style.BRIGHT}{self.t('menu_dashboard_title')}:")
506 for name, props in services_status.items():
507 state = props.get('ActiveState', 'unknown')
508 sub = props.get('SubState', 'unknown')
509
510 if state == 'active':
511 # Green and full dot for running services
512 color = Fore.GREEN
513 dot = "●"
514 info = f"{state} ({sub})"
515 elif state == 'failed':
516 # Red and info for crashes
517 color = Fore.RED
518 dot = "●"
519 reason = props.get('Result', 'error')
520 info = (f"{state} ({self.t('menu_dashboard_reason')}: {reason})")
521 else:
522 # Yellow and empty circle for stopped services
523 color = Fore.YELLOW
524 dot = "○"
525 info = f"{state} ({sub})"
526
527 # Formatted: Name left-aligned (14 chars), Info in gray
528 self.log(f" {color}{dot} {Fore.WHITE}{name:<14} {Style.DIM}[{info}]")
529 self.log("") # Separator line
530 # --- DASHBOARD BLOCK END ---
531
532 # --- MAIN MENU OPTIONS ---
533 self.log(f"1) {self.t('menu_install')}")
534
535 if is_installed:
536 self.log(f"2) {self.t('menu_update')}")
537 self.log(f"3) {self.t('menu_service_manager')}")
538 self.log(f"4) {self.t('menu_config')}")
539 self.log(f"5) {self.t('menu_logs')}")
540 self.log(f"6) {self.t('menu_deps')}")
541 else:
542 # Show as grayed out
543 self.log(Style.DIM + f"2) {self.t('menu_update')} "
544 f"{self.t('menu_locked')}")
545 self.log(Style.DIM + f"3) {self.t('menu_service_manager')} "
546 f"{self.t('menu_locked')}")
547 self.log(Style.DIM + f"4) {self.t('menu_config')} "
548 f"{self.t('menu_locked')}")
549 self.log(Style.DIM + f"5) {self.t('menu_logs')} "
550 f"{self.t('menu_locked')}")
551 self.log(Style.DIM + f"6) {self.t('menu_deps')} "
552 f"{self.t('menu_locked')}")
553
554 self.log(f"0) {self.t('menu_exit')}")
555
556 choice = input("\n> ")
557
558 if choice == "1":
559 self.install_routine()
560 elif choice in ("2", "3", "4", "5", "6") and not is_installed:
561 self.log(Fore.RED + self.t('menu_not_installed'))
562 elif choice == "2":
563 self.update_routine()
564 elif choice == "3":
565 self.service_menu()
566 elif choice == "4":
567 self.change_branch_logic()
568 elif choice == "5":
569 self.show_service_logs()
570 elif choice == "6":
571 self.sync_dependencies()
572 elif choice == "0":
573 break
574

◆ wait_for_apt()

manager.BW3Manager.wait_for_apt (   self,
  timeout = 300 
)
Waits until the APT Package Manager is free.
577 def wait_for_apt(self, timeout=300):
578 """Waits until the APT Package Manager is free."""
579 self.log(Fore.YELLOW + self.t('wait_apt_checking'))
580 start_time = time.time()
581
582 # Paths to common lock files
583 lock_files = ["/var/lib/dpkg/lock-frontend", "/var/lib/apt/lists/lock"]
584
585 while time.time() - start_time < timeout:
586 locked = False
587 for lock in lock_files:
588 # If file exists and is held by a process
589 if os.path.exists(lock):
590 # Check with fuser if a process actually accesses it
591 res = subprocess.run(["fuser", lock], capture_output=True)
592 if res.returncode == 0: # 0 means: Someone is using it
593 locked = True
594 break
595
596 if not locked:
597 return True
598
599 remaining = int(timeout - (time.time() - start_time))
600 self.log(Fore.YELLOW + self.t('wait_apt_busy').format(remaining))
601 time.sleep(5)
602
603 self.log(Fore.RED + self.t('wait_apt_timeout'))
604 return False
605

◆ install_routine()

manager.BW3Manager.install_routine (   self)
The main installation routine (formerly install.sh).
608 def install_routine(self):
609 """The main installation routine (formerly install.sh)."""
610
611 # 1a. Check if APT is free
612 if not self.wait_for_apt():
613 return # Abort if APT is still busy after 5 min
614
615 # 1b. Install system dependencies (APT)
616 apt_packages = ["git", "cmake", "build-essential", "libusb-1.0-0-dev", "pkg-config", "libpulse-dev", "libx11-dev", "python3-pip"]
617
618 self.log(f"\n{Fore.CYAN}--- {self.t('installing_sys_deps')} ---")
619 self.run_with_progress(
620 [f"DEBIAN_FRONTEND=noninteractive apt-get install -y {p}"
621 for p in apt_packages], "Apt-Packages")
622
623 # 2. Install hardware tools
624 self.check_and_update_hardware_tools()
625
626 # 3. Blacklist DVB-T (so SDR is free for radio)
627 self.log(f"\n{Fore.CYAN}--- {self.t('blacklist_sdr')} ---")
628
629 blacklist_path = "/etc/modprobe.d/blacklist-rtl.conf"
630 blacklist_content = ("blacklist dvb_usb_rtl28xxu\nblacklist rtl2832\nblacklist rtl2830")
631
632 try:
633 with open(blacklist_path, "w") as f:
634 f.write(blacklist_content)
635 self.log(f"{Fore.GREEN}✔ {self.t('blacklist_success').format(blacklist_path)}")
636 except IOError as e:
637 self.log(f"{Fore.RED}✘ {self.t('blacklist_error').format(e)}")
638 self.log(f"{Fore.YELLOW}{self.t('blacklist_warning')}")
639
640 # 4. Paths and Permissions (adapted from install.sh)
641 # Determine real user even if sudo is used
642 self.log(f"\n{Fore.CYAN}--- {self.t('perm_setup')} ---")
643
644 setup_cmds = [
645 f"mkdir -p {self.base_path}/log/install",
646 # Set owner to real user and group to www-data (for web access)
647 f"chown -R {self.real_user}:www-data {self.base_path}",
648 # 775 = User & Group may write/read, others only read
649 f"chmod -R 775 {self.base_path}",
650 # Setgid-bit for logs (new files in log inherit group)
651 f"chmod 2775 {self.base_path}/log"
652 ]
653
654 self.run_with_progress(setup_cmds, "Permissions")
655
656 # 5. Check config files
657 self.setup_example_configs()
658
659 os.system(f"chown -R {self.real_user}:www-data {self.base_path}/config")
660
661 # 6. Install initial Python dependencies (prepare main program)
662 self.log(f"\n{Fore.CYAN}--- {self.t('pip_install_header')} ---")
663 venv_pip = self.base_path / "venv" / "bin" / "pip"
664 req_file = self.base_path / "requirements-runtime.txt"
665
666 if req_file.exists():
667 tag_mapping = self.parse_requirements_tags()
668 core_packages = tag_mapping.get('core', []) + tag_mapping.get('manager', [])
669
670 if core_packages:
671 install_cmds = [f"{venv_pip} install {p}" for p in core_packages]
672 self.run_with_progress(install_cmds, "PIP Core Install")
673 self.log(Fore.YELLOW + self.t('pip_install_hint'))
674 else:
675 self.log(Fore.RED + self.t('pip_req_missing').format(self.base_path))
676
677 self._update_pip_tracking()
678 self.config['installed'] = True
679 self.save_config()
680
681 self.log(f"\n{Fore.GREEN}{Style.BRIGHT}{self.t('done_success')}")
682
683 # 7. Reboot query
684 self.ask_for_reboot()
685

◆ update_routine()

manager.BW3Manager.update_routine (   self)
Main update routine with hardware version check.
688 def update_routine(self):
689 """Main update routine with hardware version check."""
690 branch = self.config['branch']
691 venv_pip = self.base_path / "venv" / "bin" / "pip"
692 self.log(f"\n{Fore.CYAN}--- {self.t('update_fetching')} ---")
693
694 # 1. Core update fetch
695 subprocess.run(["git", "fetch", "origin", branch], cwd=self.base_path)
696
697 # 2. Check hardware versions
698 self.check_and_update_hardware_tools()
699
700 # 3. Compare hashes
701 local_hash = subprocess.run(
702 ["git", "rev-parse", "HEAD"],
703 cwd=self.base_path, capture_output=True, text=True).stdout.strip()
704 remote_hash = subprocess.run(
705 ["git", "rev-parse", f"origin/{branch}"],
706 cwd=self.base_path, capture_output=True, text=True).stdout.strip()
707 current_branch = subprocess.run(
708 ["git", "rev-parse", "--abbrev-ref", "HEAD"],
709 cwd=self.base_path, capture_output=True, text=True).stdout.strip()
710
711 # Check if we are on the correct branch
712 if local_hash == remote_hash and current_branch == branch:
713 self.log(f"{Fore.GREEN}✔ {self.t('update_not_needed')} "
714 f"(Hash: {local_hash[:7]})")
715 return
716
717 # 4. Update found or branch switch necessary
718 self.log(f"{Fore.YELLOW}★ {self.t('update_found')} "
719 f"({local_hash[:7]} -> {remote_hash[:7]})")
720
721 # 5. SECURITY QUERY
722 confirm = input(Fore.RED + Style.BRIGHT + self.t('update_warning_changes')).lower()
723 if confirm != 'y':
724 self.log(Fore.YELLOW + self.t('update_cancel_changes'))
725 return
726
727 # 6. Branch Switch
728 # 6a: Discard local changes to tracked files immediately
729 self.log(Fore.YELLOW + self.t('update_clean_local'))
730 subprocess.run(["git", "reset", "--hard", "HEAD"],
731 cwd=self.base_path, capture_output=True)
732
733 # 6b: Force branch switch (-f)
734 if current_branch != branch:
735 self.log(Fore.CYAN + f" → {self.t('update_branch_switch').format(branch)}")
736 # Use checkout -B: creates branch if needed OR resets it
737 subprocess.run(["git", "checkout", "-B", branch, f"origin/{branch}"],
738 cwd=self.base_path, capture_output=True)
739
740 # 6c: Reset hard to remote state
741 self.log(Fore.YELLOW + f" → {self.t('update_resetting')}")
742 res = subprocess.run(["git", "reset", "--hard", f"origin/{branch}"],
743 cwd=self.base_path, capture_output=True)
744
745 if res.returncode != 0:
746 self.log(Fore.RED + self.t('update_error_git'))
747 return
748
749 # 7. Track changes for PIP
750 diff = subprocess.run(
751 ["git", "diff", "--name-only", local_hash, remote_hash],
752 cwd=self.base_path, capture_output=True, text=True).stdout.splitlines()
753
754 # 8. Sync PIP requirements & Cleanup
755 self.safe_venv_cleanup()
756 self._sync_core_requirements()
757 self._update_pip_tracking()
758
759 # Dev requirements only if needed
760 if "requirements.txt" in diff:
761 self.run_with_progress([f"{venv_pip} install -r requirements.txt"], self.t('prog_pip_dev'))
762
763 # Dependencies-Check
764 if any('config/' in f for f in diff):
765 self.log(f"\n{Fore.CYAN}{self.t('menu_deps')}")
766 self.sync_dependencies()
767
768 self.log(f"\n{Fore.GREEN}{self.t('dep_success')}")
769
770 # 9. Check example configs
771 self.setup_example_configs()
772
773 # 10. Restart services
774 self.save_config()
775 self.restart_active_services()
776 self.log(f"\n{Fore.GREEN}{Style.BRIGHT}{self.t('update_success')}")
777
778 # 11. Reboot prompt
779 self.ask_for_reboot()
780

◆ _sync_core_requirements()

manager.BW3Manager._sync_core_requirements (   self)
protected
Synchronisiert Versionen aller bereits installierten Pakete gegen requirements-runtime.txt.
Neue Pakete werden NUR installiert wenn sie [core] oder [manager] getaggt sind.
781 def _sync_core_requirements(self):
782 """Synchronisiert Versionen aller bereits installierten Pakete gegen requirements-runtime.txt.
783 Neue Pakete werden NUR installiert wenn sie [core] oder [manager] getaggt sind."""
784 venv_pip = self.base_path / "venv" / "bin" / "pip"
785 tag_mapping = self.parse_requirements_tags()
786 installed = self.config.get('installed_packages', {})
787
788 # Alle Pakete aus requirements-runtime.txt mit ihren Versionen
789 req_file = self.base_path / "requirements-runtime.txt"
790 if not req_file.exists():
791 return
792
793 # Vollständige Paketliste aus requirements parsen (Name -> volle Spezifikation)
794 all_requirements = {}
795 with open(req_file, 'r') as f:
796 for line in f:
797 line = line.strip()
798 if not line or line.startswith('#'):
799 continue
800 pkg_spec = line.split('#')[0].strip()
801 if pkg_spec:
802 # Normalisierter Name als Key, volle Spezifikation als Value
803 pkg_name = pkg_spec.split('==')[0].split('>=')[0].split('~=')[0].lower().replace('-', '_').strip()
804 all_requirements[pkg_name] = pkg_spec
805
806 core_and_manager = set(
807 p.split('==')[0].split('>=')[0].lower().replace('-', '_').strip()
808 for p in tag_mapping.get('core', []) + tag_mapping.get('manager', [])
809 )
810
811 to_sync = []
812
813 for pkg_name, pkg_spec in all_requirements.items():
814 if pkg_name in installed:
815 # Bereits installiert → immer synchronisieren (up/downgrade)
816 to_sync.append(pkg_spec)
817 elif pkg_name in core_and_manager:
818 # Nicht installiert, aber core/manager → installieren
819 to_sync.append(pkg_spec)
820 # Sonst: nicht installiert, plugin-spezifisch → überspringen
821
822 if not to_sync:
823 self.log(Fore.GREEN + self.t('pip_sync_ok'))
824 return
825
826 self.log(Fore.CYAN + f" → Synchronisiere {len(to_sync)} Pakete...")
827
828 result = subprocess.run(
829 [str(venv_pip), "install", "--upgrade"] + to_sync,
830 capture_output=True, text=True
831 )
832
833 changed = []
834 for line in result.stdout.splitlines():
835 if line.startswith("Successfully installed"):
836 changed = line.replace("Successfully installed", "").strip().split()
837
838 if changed:
839 self.log(Fore.GREEN + self.t('pip_sync_updated').format(', '.join(changed)))
840 else:
841 self.log(Fore.GREEN + self.t('pip_sync_ok'))
842
843 log_file = self.base_path / "log" / "install" / "setup_log.txt"
844 with open(log_file, "a") as f:
845 f.write(result.stdout)
846 if result.stderr:
847 f.write(f"STDERR: {result.stderr}")
848

◆ compile_rtlsdr()

manager.BW3Manager.compile_rtlsdr (   self,
  commit 
)
Compiles rtl-sdr with a specific commit.
851 def compile_rtlsdr(self, commit):
852 """Compiles rtl-sdr with a specific commit."""
853 self.log(f"\n{Fore.CYAN}--- {self.t('compiling_sdr')} (Hash: {commit[:7]}) ---")
854 cmds = [
855 "rm -rf /tmp/librtlsdr", # Clean start
856 "cd /tmp && git clone https://github.com/steve-m/librtlsdr.git",
857 f"cd /tmp/librtlsdr && git checkout {commit}",
858 "cd /tmp/librtlsdr && mkdir build",
859 "cd /tmp/librtlsdr/build && cmake ../ -DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=ON",
860 "cd /tmp/librtlsdr/build && make -j$(nproc)",
861 "make install -C /tmp/librtlsdr/build",
862 "ldconfig",
863 "cp /tmp/librtlsdr/rtl-sdr.rules /etc/udev/rules.d/",
864 "rm -rf /tmp/librtlsdr"
865 ]
866 try:
867 self.run_with_progress(cmds, "RTL-SDR")
868 return True
869 except Exception as e:
870 self.log(Fore.RED + self.t('error_compile_sdr').format(e))
871 return False
872

◆ compile_multimon()

manager.BW3Manager.compile_multimon (   self,
  branch 
)
Compiles multimon-ng with a specific version.
873 def compile_multimon(self, branch):
874 """Compiles multimon-ng with a specific version."""
875 self.log(f"\n{Fore.CYAN}--- {self.t('compiling_multimon')} (Branch: {branch}) ---")
876 cmds = [
877 "rm -rf /tmp/multimon-ng",
878 f"cd /tmp && git clone --branch {branch} "
879 f"https://github.com/EliasOenal/multimon-ng.git",
880 "cd /tmp/multimon-ng && mkdir build",
881 "cd /tmp/multimon-ng/build && cmake ..",
882 "cd /tmp/multimon-ng/build && make -j$(nproc)",
883 "make install -C /tmp/multimon-ng/build",
884 "rm -rf /tmp/multimon-ng"
885 ]
886 try:
887 self.run_with_progress(cmds, "Multimon-NG")
888 return True
889 except Exception as e:
890 self.log(Fore.RED + self.t('error_compile_mm').format(e))
891 return False
892

◆ check_and_update_hardware_tools()

manager.BW3Manager.check_and_update_hardware_tools (   self)
Checks versions and recompiles hardware tools if needed.
895 def check_and_update_hardware_tools(self):
896 """Checks versions and recompiles hardware tools if needed."""
897 updated = False
898
899 # RTL-SDR Check
900 target_sdr = self.config['rtlsdr_commit']
901 if (self.config['installed_rtlsdr'] != target_sdr or
902 not os.path.exists("/usr/local/bin/rtl_fm")):
903 if self.compile_rtlsdr(target_sdr):
904 self.config['installed_rtlsdr'] = target_sdr
905 updated = True
906
907 # Multimon-NG Check
908 target_mm = self.config['multimon_branch']
909 if (self.config['installed_multimon'] != target_mm or
910 not os.path.exists("/usr/local/bin/multimon-ng")):
911 if self.compile_multimon(target_mm):
912 self.config['installed_multimon'] = target_mm
913 updated = True
914
915 if updated:
916 self.save_config()
917

◆ _update_pip_tracking()

manager.BW3Manager._update_pip_tracking (   self)
protected
Reads all installed versions and saves them to config.
920 def _update_pip_tracking(self):
921 """Reads all installed versions and saves them to config."""
922 venv_pip = self.base_path / "venv" / "bin" / "pip"
923 result = subprocess.run([str(venv_pip), "list", "--format=json"],
924 capture_output=True, text=True)
925
926 self.config['installed_packages'] = {
927 pkg['name'].lower().replace('-', '_'): pkg['version']
928 for pkg in json.loads(result.stdout)
929 }
930
931 self.save_config()
932 self.log(Fore.GREEN + self.t('pip_track_success').format(len(self.config['installed_packages'])))
933

◆ ask_for_reboot()

manager.BW3Manager.ask_for_reboot (   self)
Asks the user for a system reboot (DRY principle).
936 def ask_for_reboot(self):
937 """Asks the user for a system reboot (DRY principle)."""
938 choice = input(f"\n{Fore.YELLOW}{Style.BRIGHT} {self.t('reboot_ask')}")
939
940 if choice.lower() == 'y':
941 self.log(f"\n{Fore.CYAN}{self.t('reboot_now')}")
942 time.sleep(2)
943 subprocess.run(["shutdown", "-r", "now"])
944 else:
945 self.log(f"\n{Fore.GREEN}{self.t('reboot_skip')}")
946

◆ service_menu()

manager.BW3Manager.service_menu (   self)
Submenu for service management.
949 def service_menu(self):
950 """Submenu for service management."""
951 # automatic silent dependencies check while enter
952 self.sync_dependencies(silent=True)
953
954 while True:
955 config_dir = self.base_path / 'config'
956
957 # 1. Scan for YAML files (client/server)
958 configs = [
959 f for f in config_dir.glob("*.yaml")
960 if f.stem.startswith(('client', 'server'))
961 ]
962
963 if not configs:
964 self.log(Fore.RED + self.t('srv_not_found'))
965 return
966
967 self.log(f"\n{Fore.GREEN}=== {self.t('menu_service_manager')} ===")
968
969 self.log(Style.DIM + f" → {self.t('srv_hint_deps')}")
970
971 for cfg in configs:
972 service_name = f"bw3_{cfg.stem}.service"
973 is_active = subprocess.run(["systemctl", "is-active", "--quiet", service_name]).returncode == 0
974 status_text = (Fore.GREEN + self.t('srv_status_running') if is_active else Fore.RED + self.t('srv_status_stopped'))
975
976 self.log("\n" + self.t('srv_file_status').format(cfg.name, status_text))
977
978 # Choose action
979 if not os.path.exists(f"/etc/systemd/system/{service_name}"):
980 choice = input(self.t('srv_ask_install').format(cfg.name))
981 if choice.lower() == 'y':
982 self.install_single_service(cfg)
983 else:
984 self.log(self.t('srv_action_options'))
985 action = input(self.t('srv_action_prompt')).lower()
986 if action == 'r':
987 subprocess.run(["systemctl", "restart", service_name])
988 self.log(Fore.GREEN + self.t('srv_restarted').format(service_name))
989 elif action == 'd':
990 self.remove_single_service(service_name)
991

◆ install_single_service()

manager.BW3Manager.install_single_service (   self,
  config_file 
)
Creates a systemd service file.
992 def install_single_service(self, config_file):
993 """Creates a systemd service file."""
994 # Decide which script to start based on filename
995 script_name = ("bw_server.py" if "server" in config_file.name.lower()
996 else "bw_client.py")
997 service_name = f"bw3_{config_file.stem}.service"
998
999 service_content = f"""[Unit]
1000Description=BOSWatch3 Service: {config_file.stem}
1001After=network.target
1002
1003[Service]
1004Type=simple
1005User=root
1006WorkingDirectory={self.base_path}
1007ExecStart={self.base_path}/venv/bin/python3 {script_name} -c {config_file.name}
1008Restart=always
1009RestartSec=5
1010
1011[Install]
1012WantedBy=multi-user.target
1013"""
1014 with open(f"/etc/systemd/system/{service_name}", "w") as f:
1015 f.write(service_content)
1016
1017 subprocess.run(["systemctl", "daemon-reload"])
1018 subprocess.run(["systemctl", "enable", service_name])
1019 subprocess.run(["systemctl", "start", service_name])
1020 self.log(Fore.GREEN + self.t('srv_install_success').format(service_name))
1021

◆ remove_single_service()

manager.BW3Manager.remove_single_service (   self,
  service_name 
)
Stops and deletes a service.
1022 def remove_single_service(self, service_name):
1023 """Stops and deletes a service."""
1024 subprocess.run(["systemctl", "stop", service_name])
1025 subprocess.run(["systemctl", "disable", service_name])
1026 if os.path.exists(f"/etc/systemd/system/{service_name}"):
1027 os.remove(f"/etc/systemd/system/{service_name}")
1028 subprocess.run(["systemctl", "daemon-reload"])
1029 self.log(Fore.YELLOW + self.t('srv_remove_success').format(service_name))
1030

◆ get_required_packages()

manager.BW3Manager.get_required_packages (   self)
Scans recursively all config-files for active modules and plugins.
1033 def get_required_packages(self):
1034 """Scans recursively all config-files for active modules and plugins."""
1035 active_resources = set()
1036 config_dir = self.base_path / 'config'
1037
1038 def extract_resources(data):
1039 """Helper: Recursively searches lists and dictionaries."""
1040 if isinstance(data, dict):
1041 # If it is a plugin or module, save the technical identifier 'res'.
1042 if data.get('type') in ['plugin', 'module'] and 'res' in data:
1043 active_resources.add(data['res'])
1044
1045 # Recursively continue searching through all values ​​of the dictionary.
1046 for value in data.values():
1047 extract_resources(value)
1048 elif isinstance(data, list):
1049 # If it is a list, iterate through every element of the list.
1050 for item in data:
1051 extract_resources(item)
1052
1053 if not config_dir.exists():
1054 return active_resources
1055
1056 # Scan all .yaml files that start with 'client' or 'server'.
1057 for cfg_path in config_dir.glob("*.yaml"):
1058 if not cfg_path.stem.startswith(('client', 'server')):
1059 continue
1060
1061 try:
1062 with open(cfg_path, 'r', encoding='utf-8') as f:
1063 content = yaml.safe_load(f)
1064 if content:
1065 # Start recursive search for this file.
1066 extract_resources(content)
1067 except Exception as e:
1068 self.log(Fore.RED + self.t('dep_config_read_error').format(cfg_path.name, e))
1069
1070 return active_resources
1071

◆ sync_dependencies()

manager.BW3Manager.sync_dependencies (   self,
  silent = False 
)
Installs missing dependencies based on config-files.
1072 def sync_dependencies(self, silent=False):
1073 """Installs missing dependencies based on config-files."""
1074 if not silent:
1075 self.log(f"\n{Fore.CYAN}--- {self.t('dep_checking_header')} ---")
1076
1077 tag_mapping = self.parse_requirements_tags()
1078 active_plugins = self.get_required_packages()
1079 to_install = set()
1080
1081 # Core/Manager/Overall tags always
1082 to_install.update(tag_mapping.get('core', []))
1083 to_install.update(tag_mapping.get('manager', []))
1084 to_install.update(tag_mapping.get('overall', []))
1085
1086 # Dot-Recognition Logic (e.g., filter.modeFilter -> matches [modeFilter])
1087 for plugin in active_plugins:
1088 p_lower = plugin.lower()
1089 if p_lower in tag_mapping:
1090 to_install.update(tag_mapping[p_lower])
1091 if '.' in p_lower:
1092 sub = p_lower.split('.')[-1]
1093 if sub in tag_mapping:
1094 to_install.update(tag_mapping[sub])
1095
1096 # Version-aware missing check
1097 installed = self.config.get('installed_packages', {})
1098 missing = []
1099 for p in to_install:
1100 # Handle version specifiers: ==, >=, ~=
1101 p_name = p.split('==')[0].split('>=')[0].split('~=')[0].lower().replace('-', '_').strip()
1102 installed_ver = installed.get(p_name)
1103
1104 if installed_ver is None:
1105 missing.append(p)
1106 elif '==' in p and installed_ver != p.split('==')[1].strip():
1107 missing.append(p)
1108 elif '>=' in p:
1109 try:
1110 if packaging.version.parse(installed_ver) < packaging.version.parse(p.split('>=')[1].strip()):
1111 missing.append(p)
1112 except Exception:
1113 missing.append(p)
1114
1115 # FINAL LOGIC FLOW (The Clean Senior Way)
1116 if missing:
1117 self.log(Fore.YELLOW + self.t('dep_installing').format(len(missing)))
1118 venv_pip = self.base_path / "venv" / "bin" / "pip"
1119
1120 # Run installation
1121 self.run_with_progress([f"{venv_pip} install {p}" for p in missing], "PIP Sync")
1122 self._update_pip_tracking()
1123 self.log(Fore.GREEN + f"✔ {self.t('dep_success')}")
1124
1125 # Restart query only if not silent
1126 if not silent:
1127 confirm = input("\n" + self.t('dep_ask_restart')).lower()
1128 if confirm == 'y':
1129 self.restart_active_services()
1130 else:
1131 # Everything okay
1132 if not silent:
1133 self.log(Fore.GREEN + f"✔ {self.t('dep_all_ok')}")
1134

◆ parse_requirements_tags()

manager.BW3Manager.parse_requirements_tags (   self)
Reads requirements-runtime.txt and maps packages to tags with multi-tag [tag1][tag2]
1135 def parse_requirements_tags(self):
1136 """Reads requirements-runtime.txt and maps packages to tags with multi-tag [tag1][tag2]"""
1137 req_file = self.base_path / "requirements-runtime.txt"
1138 mapping = {'core': [], 'manager': []}
1139
1140 if not req_file.exists():
1141 return mapping
1142
1143 with open(req_file, 'r', encoding='utf-8') as f:
1144 for line in f:
1145 line = line.strip()
1146 if not line or line.startswith('#'):
1147 continue
1148
1149 # split packages from comments
1150 parts = line.split('#', 1)
1151 package = parts[0].strip()
1152
1153 if len(parts) > 1:
1154 comment = parts[1].lower()
1155 # Findet alle [tags] in einer Zeile
1156 tags = re.findall(r'\[(.*?)\]', comment)
1157 if tags:
1158 for tag in tags:
1159 tag = tag.strip()
1160 if tag not in mapping:
1161 mapping[tag] = []
1162 mapping[tag].append(package)
1163 continue
1164
1165 # no tags -> standard: core
1166 mapping['core'].append(package)
1167 return mapping
1168
1169

Field Documentation

◆ base_path

manager.BW3Manager.base_path

◆ install_path

manager.BW3Manager.install_path

◆ real_user

manager.BW3Manager.real_user

◆ config_path

manager.BW3Manager.config_path

◆ trans_path

manager.BW3Manager.trans_path

◆ defaults

manager.BW3Manager.defaults

◆ lang

manager.BW3Manager.lang

◆ config

manager.BW3Manager.config

◆ translations

manager.BW3Manager.translations