Created
December 29, 2025 07:12
-
-
Save minimasoft/b9133c64102a8738722754633bf7889a to your computer and use it in GitHub Desktop.
sample dashboard for mobile starlink mini, uses https://github.com/sparky8512/starlink-grpc-tools/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/home/u/starlink/.venv/bin/python | |
| import tkinter as tk | |
| from tkinter import font as tkfont | |
| import time | |
| import sys | |
| import requests | |
| import math | |
| import threading | |
| from datetime import datetime | |
| import starlink_grpc | |
| # --- CONFIGURATION --- | |
| W, H = 1024, 600 | |
| DISH_IP = "192.168.100.1:9200" | |
| WEATHER_URL = "https://api.open-meteo.com/v1/forecast" | |
| SOLARIZED = { | |
| 'light': {'base': '#fdf6e3', 'text': '#657b83', 'accent': '#268bd2', 'green': '#859900', 'red': '#dc322f'}, | |
| 'dark': {'base': '#002b36', 'text': '#93a1a1', 'accent': '#268bd2', 'green': '#859900', 'red': '#dc322f'}, | |
| } | |
| def degrees_to_cardinal(d): | |
| dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] | |
| ix = int((d + 11.25) / 22.5) | |
| return dirs[ix % 16] | |
| def haversine(lat1, lon1, lat2, lon2): | |
| R = 6371000 | |
| phi1, phi2 = math.radians(lat1), math.radians(lat2) | |
| dphi = math.radians(lat2 - lat1) | |
| dlambda = math.radians(lon2 - lon1) | |
| a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2 | |
| return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1-a)) | |
| def calculate_bearing(lat1, lon1, lat2, lon2): | |
| phi1, phi2 = math.radians(lat1), math.radians(lat2) | |
| dlambda = math.radians(lon2 - lon1) | |
| y = math.sin(dlambda) * math.cos(phi2) | |
| x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlambda) | |
| return (math.degrees(math.atan2(y, x)) + 360) % 360 | |
| STATUS_MAP = { | |
| "CONNECTED": "🛰️ CONECTADO", | |
| "SEARCHING": "🔍 BUSCANDO", | |
| "BOOTING": "🚀 INICIANDO", | |
| "STOWED": "📦 GUARDADO", | |
| "DISH_UNREACHABLE": "❌ SIN CONEXIÓN" | |
| } | |
| class DashboardTK: | |
| def __init__(self): | |
| self.root = tk.Tk() | |
| self.root.title("Starlink Dashboard") | |
| self.root.geometry(f"{W}x{H}") | |
| # Try full screen | |
| try: | |
| self.root.attributes('-fullscreen', True) | |
| except: | |
| pass | |
| self.root.config(cursor="none") # Hide cursor | |
| self.font_big = tkfont.Font(family="Noto Sans", size=40, weight="bold") | |
| self.font_med = tkfont.Font(family="Noto Sans", size=30) | |
| self.font_small = tkfont.Font(family="Noto Sans", size=20) | |
| self.data = { | |
| "state": "SEARCHING", | |
| "down_mbps": 0.0, | |
| "up_mbps": 0.0, | |
| "power": 0.0, | |
| "obstruction": 0.0, | |
| "lat": 0.0, "lon": 0.0, "alt": 0.0, | |
| "speed": 0.0, "heading": 0.0, "crosswind": 0.0, | |
| "snr_ok": False, | |
| "weather": { | |
| "temp": "...", "wind": "...", "clouds": "...", "rain": "...", | |
| "wind_deg": 0.0, "wind_speed": 0.0, | |
| "forecast": "Cargando pronóstico..." | |
| } | |
| } | |
| self.prev_gps = None | |
| self.last_weather_time = 0 | |
| self.last_starlink_time = 0 | |
| self.context = starlink_grpc.ChannelContext(target=DISH_IP) | |
| self.running = True | |
| # UI Elements | |
| self.main_frame = tk.Frame(self.root) | |
| self.main_frame.pack(fill=tk.BOTH, expand=True) | |
| self.lbl_state = tk.Label(self.main_frame, font=self.font_big) | |
| self.lbl_state.place(relx=0.5, y=50, anchor=tk.CENTER) | |
| self.lbl_speed = tk.Label(self.main_frame, font=self.font_med) | |
| self.lbl_speed.place(relx=0.5, y=120, anchor=tk.CENTER) | |
| self.lbl_signal = tk.Label(self.main_frame, font=self.font_small) | |
| self.lbl_signal.place(relx=0.5, y=180, anchor=tk.CENTER) | |
| self.lbl_gps = tk.Label(self.main_frame, font=self.font_med) | |
| self.lbl_gps.place(relx=0.5, y=240, anchor=tk.CENTER) | |
| self.lbl_travel = tk.Label(self.main_frame, font=self.font_med) | |
| self.lbl_travel.place(relx=0.5, y=300, anchor=tk.CENTER) | |
| self.lbl_alt = tk.Label(self.main_frame, font=self.font_small) | |
| self.lbl_alt.place(relx=0.5, y=360, anchor=tk.CENTER) | |
| self.lbl_weather_main = tk.Label(self.main_frame, font=self.font_med) | |
| self.lbl_weather_main.place(relx=0.5, y=420, anchor=tk.CENTER) | |
| self.lbl_weather_det = tk.Label(self.main_frame, font=self.font_small) | |
| self.lbl_weather_det.place(relx=0.5, y=480, anchor=tk.CENTER) | |
| self.lbl_forecast = tk.Label(self.main_frame, font=self.font_small) | |
| self.lbl_forecast.place(relx=0.5, y=540, anchor=tk.CENTER) | |
| self.root.bind("<Escape>", lambda e: self.quit()) | |
| self.root.bind("<Button-1>", lambda e: self.quit()) | |
| self.update_ui_loop() | |
| self.fetch_loop() | |
| def quit(self): | |
| self.running = False | |
| self.root.destroy() | |
| sys.exit(0) | |
| def get_theme(self): | |
| return SOLARIZED['light'] if 6 <= datetime.now().hour < 19 else SOLARIZED['dark'] | |
| def fetch_starlink(self): | |
| try: | |
| status, obstruct, alerts = starlink_grpc.status_data(self.context) | |
| self.data['state'] = status.get('state', "UNKNOWN") | |
| self.data['down_mbps'] = (status.get('downlink_throughput_bps') or 0.0) / 1e6 | |
| self.data['up_mbps'] = (status.get('uplink_throughput_bps') or 0.0) / 1e6 | |
| self.data['obstruction'] = (status.get('fraction_obstructed') or 0.0) * 100 | |
| self.data['snr_ok'] = status.get('is_snr_above_noise_floor', False) | |
| loc = starlink_grpc.location_data(self.context) | |
| if loc.get('latitude') is not None: | |
| curr_lat = loc['latitude'] | |
| curr_lon = loc['longitude'] | |
| curr_alt = loc.get('altitude') or 0.0 | |
| curr_time = time.time() | |
| if self.prev_gps: | |
| p_lat, p_lon, p_time = self.prev_gps | |
| dt = curr_time - p_time | |
| if dt > 0: | |
| dist = haversine(p_lat, p_lon, curr_lat, curr_lon) | |
| speed_kph = (dist / dt) * 3.6 | |
| if dist > 0.5: | |
| self.data['speed'] = speed_kph | |
| self.data['heading'] = calculate_bearing(p_lat, p_lon, curr_lat, curr_lon) | |
| else: | |
| self.data['speed'] = 0.0 | |
| self.prev_gps = (curr_lat, curr_lon, curr_time) | |
| self.data['lat'] = curr_lat | |
| self.data['lon'] = curr_lon | |
| self.data['alt'] = curr_alt | |
| stats = starlink_grpc.history_stats(1, context=self.context) | |
| if len(stats) >= 7: | |
| power_dict = stats[6] | |
| self.data['power'] = power_dict.get('latest_power') or 0.0 | |
| if self.data['weather']['wind_speed'] > 0: | |
| angle_diff = math.radians(abs(self.data['weather']['wind_deg'] - self.data['heading'])) | |
| self.data['crosswind'] = self.data['weather']['wind_speed'] * abs(math.sin(angle_diff)) | |
| except Exception: | |
| pass | |
| def fetch_weather(self): | |
| if self.data['lat'] == 0: return | |
| try: | |
| params = { | |
| "latitude": self.data['lat'], "longitude": self.data['lon'], | |
| "current_weather": "true", | |
| "hourly": "precipitation_probability,cloudcover", | |
| "daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max", | |
| "timezone": "auto", "forecast_days": 2 | |
| } | |
| r = requests.get(WEATHER_URL, params=params, timeout=5).json() | |
| curr = r.get('current_weather', {}) | |
| cloud_now = 0 | |
| rain_now = 0 | |
| if 'hourly' in r: | |
| cloud_now = r['hourly'].get('cloudcover', [0])[0] | |
| rain_now = r['hourly'].get('precipitation_probability', [0])[0] | |
| tomorrow = "Pronóstico no disponible" | |
| if 'daily' in r: | |
| d = r['daily'] | |
| tomorrow = f"Mañana: 🌡️ {d['temperature_2m_min'][1]}°/{d['temperature_2m_max'][1]}°C 💧 {d['precipitation_probability_max'][1]}%" | |
| self.data['weather'] = { | |
| "temp": f"🌡️ {curr.get('temperature', '?')}°C", | |
| "wind": f"💨 {curr.get('windspeed', '?')} km/h ({degrees_to_cardinal(float(curr.get('winddirection', 0)))})", | |
| "wind_deg": float(curr.get('winddirection', 0)), | |
| "wind_speed": float(curr.get('windspeed', 0)), | |
| "clouds": f"☁️ Nubes: {cloud_now}%", | |
| "rain": f"💧 Lluvia: {rain_now}%", | |
| "forecast": tomorrow | |
| } | |
| except Exception as e: | |
| self.data['weather'] = {"temp": "Error", "wind": "", "clouds": "", "rain": "", "forecast": str(e)} | |
| def fetch_loop(self): | |
| def loop(): | |
| while self.running: | |
| now = time.time() | |
| if now - self.last_starlink_time > 2: | |
| self.fetch_starlink() | |
| self.last_starlink_time = now | |
| if (self.data['weather']['temp'] == "..." and self.data['lat'] != 0) or (now - self.last_weather_time > 900): | |
| self.fetch_weather() | |
| self.last_weather_time = now | |
| time.sleep(1) | |
| t = threading.Thread(target=loop, daemon=True) | |
| t.start() | |
| def update_ui_loop(self): | |
| if not self.running: return | |
| theme = self.get_theme() | |
| bg = theme['base'] | |
| fg = theme['text'] | |
| accent = theme['accent'] | |
| self.main_frame.config(bg=bg) | |
| self.root.config(bg=bg) | |
| # State | |
| state_str = STATUS_MAP.get(self.data['state'], "❓ " + self.data['state']) | |
| state_col = theme['accent'] if self.data['state'] == "CONNECTED" else theme['red'] | |
| self.lbl_state.config(text=state_str, bg=bg, fg=state_col) | |
| # Speed | |
| speed_str = f"↓ {self.data['down_mbps']:.1f} ↑ {self.data['up_mbps']:.1f} Mbps" | |
| self.lbl_speed.config(text=speed_str, bg=bg, fg=theme['green']) | |
| # Signal | |
| snr_icon = "✅" if self.data['snr_ok'] else "⚠️" | |
| signal_str = f"{snr_icon} Signal OK | 🚫 Obs: {self.data['obstruction']:.1f}% | ⚡ {self.data['power']:.1f}W" | |
| self.lbl_signal.config(text=signal_str, bg=bg, fg=fg) | |
| # GPS | |
| lat_ns = "S" if self.data['lat'] < 0 else "N" | |
| lon_ew = "O" if self.data['lon'] < 0 else "E" | |
| gps_str = f"📍 {abs(self.data['lat']):.4f}° {lat_ns}, {abs(self.data['lon']):.4f}° {lon_ew}" | |
| self.lbl_gps.config(text=gps_str, bg=bg, fg=fg) | |
| # Travel | |
| travel_card = degrees_to_cardinal(self.data['heading']) | |
| travel_str = f"🏎️ {self.data['speed']:.1f} km/h 🧭 {self.data['heading']:.0f}° ({travel_card})" | |
| self.lbl_travel.config(text=travel_str, bg=bg, fg=accent) | |
| # Alt | |
| alt_str = f"⛰️ Altitud: {int(self.data['alt'])} msmn" | |
| self.lbl_alt.config(text=alt_str, bg=bg, fg=fg) | |
| # Weather | |
| w = self.data['weather'] | |
| cross_str = f" | 🌪️ V. Cruzado: {self.data['crosswind']:.1f} km/h" if self.data['crosswind'] > 1.0 else "" | |
| temp_cross_str = f"{w['temp']}{cross_str}" | |
| self.lbl_weather_main.config(text=temp_cross_str, bg=bg, fg=accent) | |
| det1 = f"{w['wind']} | {w['clouds']} | {w['rain']}" | |
| self.lbl_weather_det.config(text=det1, bg=bg, fg=fg) | |
| self.lbl_forecast.config(text=w['forecast'], bg=bg, fg=fg) | |
| self.root.after(100, self.update_ui_loop) | |
| def run(self): | |
| self.root.mainloop() | |
| if __name__ == "__main__": | |
| DashboardTK().run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment