Skip to content

Instantly share code, notes, and snippets.

@minimasoft
Created December 29, 2025 07:12
Show Gist options
  • Select an option

  • Save minimasoft/b9133c64102a8738722754633bf7889a to your computer and use it in GitHub Desktop.

Select an option

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/
#!/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