From 19bb6afce447dd52b43b9fc4bde9dea418efc01a Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:37:47 +0000 Subject: [PATCH 01/67] a lot of stuff --- radiosonde/docs.md | 20 ++++- radiosonde/radiosonde.py | 169 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 3 deletions(-) diff --git a/radiosonde/docs.md b/radiosonde/docs.md index f2b8812..b673e18 100644 --- a/radiosonde/docs.md +++ b/radiosonde/docs.md @@ -52,9 +52,23 @@ The `Radiosonde` cog allows you to track radiosondes (weather balloons) using th - Default is 300 seconds (5 minutes). - Example: `[p]sonde interval 60` (check every minute) -- **`[p]sonde site `** - - Look up a radiosonde launch site by station ID (from the [SondeHub sites](https://api.v2.sondehub.org/sites) list). Shows name, position, altitude, sonde types, and launch times. - - Example: `[p]sonde site 03953` or `[p]sonde site 94767` +- **`[p]sonde site `** + - Look up a radiosonde launch site by **station ID** or by **name** (substring, case-insensitive). Shows name, position, altitude, sonde types, and launch times. If multiple names match, lists them with their IDs so you can pick one. + - Examples: `[p]sonde site 10238` | `[p]sonde site Bergen-Hohne` | `[p]sonde site Germany` + +- **`[p]sonde history [limit]`** + - Show recent telemetry history for a radiosonde serial. `limit` defaults to 25. + - Example: `[p]sonde history U1234567 10` + +- **`[p]sonde nearby [distance]`** + - List sondes near a latitude/longitude within `distance` (units passed directly to the SondeHub API; default `100`). + - Example: `[p]sonde nearby 53.3 -6.2 500` + +- **`[p]sonde realtime`** + - Fetch and display the MQTT-over-WebSocket endpoint returned by `/sondes/websocket` so admins can configure realtime listeners. + +- **`[p]sonde listeners`** + - Show aggregated listener/uploader statistics from `/listeners/stats`. --- diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index a5b19a9..cba32b3 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -65,6 +65,73 @@ async def fetch_sites(self): except OSError as e: return {}, f"Network/OS error: {type(e).__name__}: {e}" + async def fetch_telemetry(self, serial: str): + """Fetch telemetry history for a given sonde serial. Returns (data, error).""" + url = f"https://api.v2.sondehub.org/sondes/{serial}" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + return {}, f"API returned HTTP {resp.status}" + data = await resp.json() + return data, None + except asyncio.TimeoutError: + return {}, "Request timed out after 15 seconds" + except aiohttp.ClientConnectorError as e: + return {}, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" + except aiohttp.ClientError as e: + return {}, f"Request error: {type(e).__name__}: {e}" + except OSError as e: + return {}, f"Network/OS error: {type(e).__name__}: {e}" + + async def fetch_sondes_near(self, lat: float, lon: float, distance: float = 100.0): + """Query sondes by location. `distance` is passed directly to API (units used by API). + Returns (data_dict, error).""" + url = f"https://api.v2.sondehub.org/sondes?lat={lat}&lon={lon}&distance={distance}" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + return {}, f"API returned HTTP {resp.status}" + data = await resp.json() + return data if isinstance(data, dict) else {}, None + except asyncio.TimeoutError: + return {}, "Request timed out after 15 seconds" + except aiohttp.ClientConnectorError as e: + return {}, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" + except aiohttp.ClientError as e: + return {}, f"Request error: {type(e).__name__}: {e}" + except OSError as e: + return {}, f"Network/OS error: {type(e).__name__}: {e}" + + async def fetch_realtime_endpoint(self): + """Return the MQTT-over-WebSocket endpoint from /sondes/websocket.""" + url = "https://api.v2.sondehub.org/sondes/websocket" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status != 200: + return None, f"API returned HTTP {resp.status}" + data = await resp.json() + return data, None + except Exception as e: + return None, str(e) + + async def fetch_listeners_stats(self): + """Fetch aggregated listener/uploader statistics from /listeners/stats.""" + url = "https://api.v2.sondehub.org/listeners/stats" + try: + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + return {}, f"API returned HTTP {resp.status}" + data = await resp.json() + return data if isinstance(data, dict) else {}, None + except asyncio.TimeoutError: + return {}, "Request timed out after 15 seconds" + except aiohttp.ClientConnectorError as e: + return {}, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" + except aiohttp.ClientError as e: + return {}, f"Request error: {type(e).__name__}: {e}" + except OSError as e: + return {}, f"Network/OS error: {type(e).__name__}: {e}" + async def update_sondes(self): await self.bot.wait_until_ready() while not self.bot.is_closed(): @@ -263,3 +330,105 @@ async def site(self, ctx, query: str): if len(matches) > 15: lines.append(f"*… and {len(matches) - 15} more. Narrow your search.*") await ctx.send("\n".join(lines)) + + @sonde.command() + async def history(self, ctx, serial: str, limit: int = 25): + """Show recent telemetry history for a radiosonde serial.""" + async with ctx.typing(): + data, error = await self.fetch_telemetry(serial) + if error or not data: + detail = f" {error}" if error else "" + await ctx.send(f"Could not fetch telemetry for `{serial}`.{detail}") + return + # Try to find telemetry list in response + telemetry = None + if isinstance(data, dict): + telemetry = data.get("telemetry") or data.get("history") or data.get("data") + if telemetry is None and isinstance(data, list): + telemetry = data + if not telemetry: + await ctx.send(f"No telemetry history found for `{serial}`.") + return + # Take last `limit` points + points = telemetry[-limit:] + lines = [f"**Telemetry for {serial} (last {len(points)})**"] + for p in reversed(points): + t = p.get("time") or p.get("timestamp") or p.get("ts") or "—" + lat = p.get("lat") if p.get("lat") is not None else "—" + lon = p.get("lon") if p.get("lon") is not None else "—" + alt = p.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "—") + lines.append(f"{t} — Lat: {lat} | Lon: {lon} | Alt: {alt_str}") + await ctx.send("\n".join(lines)) + + @sonde.command() + async def nearby(self, ctx, lat: float, lon: float, distance: float = 100.0): + """List sondes near a given lat/lon within `distance` (API units).""" + async with ctx.typing(): + data, error = await self.fetch_sondes_near(lat, lon, distance) + if error or not data: + detail = f" {error}" if error else "" + await ctx.send(f"Could not query sondes near location.{detail}") + return + # data expected as dict keyed by serial + if not isinstance(data, dict) or not data: + await ctx.send("No sondes found near that location.") + return + lines = [f"**Sondes within {distance} of {lat},{lon}**"] + for sid, s in sorted(data.items(), key=lambda x: x[0])[:25]: + la = s.get("lat", "—") + lo = s.get("lon", "—") + alt = s.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "—") + lines.append(f"`{sid}` — Lat: {la} | Lon: {lo} | Alt: {alt_str}") + if len(data) > 25: + lines.append(f"… and {len(data) - 25} more.") + await ctx.send("\n".join(lines)) + + @sonde.command() + async def realtime(self, ctx): + """Show the MQTT-over-WebSocket endpoint used for realtime sonde streaming.""" + async with ctx.typing(): + data, error = await self.fetch_realtime_endpoint() + if error or not data: + detail = f" {error}" if error else "" + await ctx.send(f"Could not fetch realtime endpoint from API.{detail}") + return + # Present the returned JSON or common `url` key + url = None + if isinstance(data, dict): + url = data.get("url") or data.get("endpoint") or data.get("ws") + if not url: + await ctx.send(f"Realtime endpoint: {data}") + return + await ctx.send(f"Realtime MQTT-over-WebSocket endpoint: {url}") + + @sonde.command() + async def listeners(self, ctx): + """Show aggregated listener/uploader statistics from SondeHub.""" + async with ctx.typing(): + data, error = await self.fetch_listeners_stats() + if error or not data: + detail = f" {error}" if error else "" + await ctx.send(f"Could not fetch listener stats.{detail}") + return + # Present a brief summary of top-level keys + lines = ["**Listener statistics (summary)**"] + # If known fields exist, show them + if isinstance(data, dict): + # common fields: uploaders, stations, listeners + if "uploaders" in data: + lines.append(f"Uploaders: {len(data.get('uploaders') or [])}") + if "stations" in data: + lines.append(f"Stations: {len(data.get('stations') or [])}") + if "listeners" in data: + lines.append(f"Listeners: {len(data.get('listeners') or [])}") + # Fallback: show some top-level numeric values + for k, v in list(data.items())[:10]: + if k in ("uploaders", "stations", "listeners"): + continue + if isinstance(v, (int, float, str)): + lines.append(f"{k}: {v}") + else: + lines.append(str(data)) + await ctx.send("\n".join(lines)) From c7a00b0bc35381ce5c9a5f7d17fac4bb3480537e Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:45:21 +0000 Subject: [PATCH 02/67] Update radiosonde.py --- radiosonde/radiosonde.py | 46 ++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index cba32b3..cf2a979 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -67,21 +67,24 @@ async def fetch_sites(self): async def fetch_telemetry(self, serial: str): """Fetch telemetry history for a given sonde serial. Returns (data, error).""" - url = f"https://api.v2.sondehub.org/sondes/{serial}" + # Individual sonde telemetry is served at /sonde/{serial} (singular) + url = f"https://api.v2.sondehub.org/sonde/{serial}" try: async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status != 200: - return {}, f"API returned HTTP {resp.status}" + if resp.status == 404: + return None, f"Not found (HTTP 404): no telemetry for {serial}" + return None, f"API returned HTTP {resp.status}" data = await resp.json() return data, None except asyncio.TimeoutError: - return {}, "Request timed out after 15 seconds" + return None, "Request timed out after 15 seconds" except aiohttp.ClientConnectorError as e: - return {}, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" + return None, f"Connection failed: {e.os_error.strerror if e.os_error else str(e)}" except aiohttp.ClientError as e: - return {}, f"Request error: {type(e).__name__}: {e}" + return None, f"Request error: {type(e).__name__}: {e}" except OSError as e: - return {}, f"Network/OS error: {type(e).__name__}: {e}" + return None, f"Network/OS error: {type(e).__name__}: {e}" async def fetch_sondes_near(self, lat: float, lon: float, distance: float = 100.0): """Query sondes by location. `distance` is passed directly to API (units used by API). @@ -336,9 +339,13 @@ async def history(self, ctx, serial: str, limit: int = 25): """Show recent telemetry history for a radiosonde serial.""" async with ctx.typing(): data, error = await self.fetch_telemetry(serial) - if error or not data: - detail = f" {error}" if error else "" - await ctx.send(f"Could not fetch telemetry for `{serial}`.{detail}") + if error: + await ctx.send( + f"Could not fetch telemetry for `{serial}`: {error}. Check the serial number or try again later." + ) + return + if not data: + await ctx.send(f"No telemetry found for `{serial}`. It may be expired or never uploaded.") return # Try to find telemetry list in response telemetry = None @@ -366,9 +373,8 @@ async def nearby(self, ctx, lat: float, lon: float, distance: float = 100.0): """List sondes near a given lat/lon within `distance` (API units).""" async with ctx.typing(): data, error = await self.fetch_sondes_near(lat, lon, distance) - if error or not data: - detail = f" {error}" if error else "" - await ctx.send(f"Could not query sondes near location.{detail}") + if error: + await ctx.send(f"Could not query sondes near location: {error}. Check parameters or try again later.") return # data expected as dict keyed by serial if not isinstance(data, dict) or not data: @@ -390,9 +396,11 @@ async def realtime(self, ctx): """Show the MQTT-over-WebSocket endpoint used for realtime sonde streaming.""" async with ctx.typing(): data, error = await self.fetch_realtime_endpoint() - if error or not data: - detail = f" {error}" if error else "" - await ctx.send(f"Could not fetch realtime endpoint from API.{detail}") + if error: + await ctx.send(f"Could not fetch realtime endpoint: {error}. Try again later or check API status.") + return + if not data: + await ctx.send("Realtime endpoint returned unexpected data; try again later.") return # Present the returned JSON or common `url` key url = None @@ -408,9 +416,11 @@ async def listeners(self, ctx): """Show aggregated listener/uploader statistics from SondeHub.""" async with ctx.typing(): data, error = await self.fetch_listeners_stats() - if error or not data: - detail = f" {error}" if error else "" - await ctx.send(f"Could not fetch listener stats.{detail}") + if error: + await ctx.send(f"Could not fetch listener stats: {error}. Try again later.") + return + if not data: + await ctx.send("No listener statistics available at the moment.") return # Present a brief summary of top-level keys lines = ["**Listener statistics (summary)**"] From e37c0071e3e3b89ec470421d698c752428c7289f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:46:46 +0000 Subject: [PATCH 03/67] more --- radiosonde/radiosonde.py | 92 ++++++++++++++++++++++++++++------------ 1 file changed, 66 insertions(+), 26 deletions(-) diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index cf2a979..850caa9 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -21,6 +21,8 @@ def __init__(self, bot): self.session = aiohttp.ClientSession() self.bg_task = self.bot.loop.create_task(self.update_sondes()) + # track last update times per guild to respect configured intervals + self._last_updates = {} def cog_unload(self): self.bg_task.cancel() @@ -138,40 +140,78 @@ async def fetch_listeners_stats(self): async def update_sondes(self): await self.bot.wait_until_ready() while not self.bot.is_closed(): + now = self.bot.loop.time() for guild in self.bot.guilds: guild_config = await self.config.guild(guild).all() tracked = guild_config.get("tracked_sondes", []) channel_id = guild_config.get("update_channel") interval = guild_config.get("update_interval", 300) - if tracked and channel_id: - sondes_data, _ = await self.fetch_sondes() - channel = self.bot.get_channel(channel_id) - if not channel: + if not tracked or not channel_id: + continue + + last = self._last_updates.get(guild.id, 0) + if now - last < interval: + continue + + sondes_data, error = await self.fetch_sondes() + channel = self.bot.get_channel(channel_id) + if error: + # Inform channel of failures optionally (only once) + try: + if channel: + await channel.send(f"Could not fetch sondes for updates: {error}") + except Exception: + pass + continue + if not channel: + continue + + for sonde_id in tracked: + sonde = sondes_data.get(sonde_id) + if not sonde: + await channel.send(f"**{sonde_id}** — No current data (not in latest API)") continue - for sonde_id in tracked: - sonde = sondes_data.get(sonde_id) - if sonde: - vel_h = sonde.get("vel_h") - vel_v = sonde.get("vel_v") - # Calculate speed from horizontal and vertical velocity - if vel_h is not None and vel_v is not None: - speed = (vel_h**2 + vel_v**2)**0.5 - elif vel_h is not None: - speed = vel_h - else: - speed = None - speed_str = f"{speed:.1f} m/s" if speed is not None else "—" - msg = ( - f"**Sonde {sonde_id} Update**\n" - f"Lat: {sonde.get('lat')}\n" - f"Lon: {sonde.get('lon')}\n" - f"Alt: {sonde.get('alt'):.1f} m\n" - f"Speed: {speed_str}\n" - ) - await channel.send(msg) + msg = self._format_sonde_message(sonde_id, sonde) + await channel.send(msg) + + self._last_updates[guild.id] = now await asyncio.sleep(1) # small delay between guilds - await asyncio.sleep(60) # wait 1 minute before next batch + await asyncio.sleep(5) # short polling delay + + def _format_sonde_message(self, sonde_id: str, sonde: dict) -> str: + """Create a safe, readable message for a single sonde dict.""" + def fmt_num(v, prec=5): + return f"{v:.{prec}f}" if isinstance(v, (int, float)) else (str(v) if v is not None else "—") + + lat = fmt_num(sonde.get("lat"), 5) + lon = fmt_num(sonde.get("lon"), 5) + alt = sonde.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "—") + + vel_h = sonde.get("vel_h") + vel_v = sonde.get("vel_v") + if isinstance(vel_h, (int, float)) and isinstance(vel_v, (int, float)): + speed = (vel_h ** 2 + vel_v ** 2) ** 0.5 + elif isinstance(vel_h, (int, float)): + speed = vel_h + else: + speed = None + speed_str = f"{speed:.1f} m/s" if speed is not None else "—" + + heading = sonde.get("heading") + sats = sonde.get("sats") + temp = sonde.get("temp") + batt = sonde.get("batt") + + lines = [f"**Sonde {sonde_id} Update**"] + lines.append(f"Lat: {lat} | Lon: {lon} | Alt: {alt_str}") + lines.append(f"Speed: {speed_str} | Heading: {heading if heading is not None else '—'}") + lines.append(f"Sats: {sats if sats is not None else '—'} | Temp: {temp if temp is not None else '—'}°C | Batt: {batt if batt is not None else '—'} V") + uploader = sonde.get("uploader_callsign") or sonde.get("uploader") + if uploader: + lines.append(f"Uploader: {uploader}") + return "\n".join(lines) @commands.group() async def sonde(self, ctx): From 43af7151e1d874073aa74e67ebaef2a4493fab30 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:49:55 +0000 Subject: [PATCH 04/67] embeds for radiosonde cog --- radiosonde/radiosonde.py | 75 +++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index 850caa9..e5d519e 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -264,30 +264,65 @@ async def status(self, ctx): f"Could not fetch sonde data from the API.{detail} Try again later." ) return - lines = [] + embeds = [] for sonde_id in tracked: s = sondes_data.get(sonde_id) if s is None: - lines.append(f"**{sonde_id}** — No current data (not in latest API)") + e = discord.Embed(title=f"{sonde_id}", description="No current data (not in latest API)", colour=0xDD5555) + embeds.append(e) continue - lat = s.get("lat", "—") - lon = s.get("lon", "—") - alt = s.get("alt") - vel_h = s.get("vel_h") - vel_v = s.get("vel_v") - # Calculate speed from horizontal and vertical velocity - if vel_h is not None and vel_v is not None: - speed = (vel_h**2 + vel_v**2)**0.5 - elif vel_h is not None: - speed = vel_h - else: - speed = None - alt_str = f"{alt:.1f} m" if alt is not None else "—" - vel_str = f"{speed:.1f} m/s" if speed is not None else "—" - lines.append( - f"**{sonde_id}** — Lat: {lat} | Lon: {lon} | Alt: {alt_str} | Speed: {vel_str}" - ) - await ctx.send("**Tracked sondes status**\n" + "\n".join(lines)) + embeds.append(self._sonde_to_embed(sonde_id, s)) + + # Send embeds in small groups to avoid hitting limits + batch = [] + for e in embeds: + batch.append(e) + if len(batch) >= 5: + # Discord API supports multiple embeds; send this batch + await ctx.send(embeds=batch) + batch = [] + if batch: + await ctx.send(embeds=batch) + + def _sonde_to_embed(self, sonde_id: str, sonde: dict) -> discord.Embed: + """Build a Discord embed summarizing a sonde's current data.""" + def fmt_num(v, prec=5): + return f"{v:.{prec}f}" if isinstance(v, (int, float)) else (str(v) if v is not None else "—") + + lat = fmt_num(sonde.get("lat"), 5) + lon = fmt_num(sonde.get("lon"), 5) + alt = sonde.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "—") + + vel_h = sonde.get("vel_h") + vel_v = sonde.get("vel_v") + if isinstance(vel_h, (int, float)) and isinstance(vel_v, (int, float)): + speed = (vel_h ** 2 + vel_v ** 2) ** 0.5 + elif isinstance(vel_h, (int, float)): + speed = vel_h + else: + speed = None + speed_str = f"{speed:.1f} m/s" if speed is not None else "—" + + heading = sonde.get("heading") + sats = sonde.get("sats") + temp = sonde.get("temp") + batt = sonde.get("batt") + uploader = sonde.get("uploader_callsign") or sonde.get("uploader") + + title = f"Sonde {sonde_id}" + desc = f"Lat: {lat} | Lon: {lon}\nAlt: {alt_str} | Speed: {speed_str}" + e = discord.Embed(title=title, description=desc, colour=0x55AAFF) + e.add_field(name="Heading", value=str(heading) if heading is not None else "—", inline=True) + e.add_field(name="Sats", value=str(sats) if sats is not None else "—", inline=True) + e.add_field(name="Temp (°C)", value=str(temp) if temp is not None else "—", inline=True) + e.add_field(name="Battery (V)", value=str(batt) if batt is not None else "—", inline=True) + if uploader: + e.set_footer(text=f"Uploader: {uploader}") + last = sonde.get("last_seen") or sonde.get("datetime") or sonde.get("time") + if last: + e.add_field(name="Last seen", value=str(last), inline=False) + return e @sonde.command() async def setchannel(self, ctx, channel: discord.TextChannel): From 819e3d0a8ae26c3fdc0354c3055a7653def7daef Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:53:01 +0000 Subject: [PATCH 05/67] Update radiosonde.py --- radiosonde/radiosonde.py | 92 +++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index e5d519e..11dd4c3 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -170,10 +170,11 @@ async def update_sondes(self): for sonde_id in tracked: sonde = sondes_data.get(sonde_id) if not sonde: - await channel.send(f"**{sonde_id}** — No current data (not in latest API)") + e = discord.Embed(title=f"{sonde_id}", description="No current data (not in latest API)", colour=0xDD5555) + await channel.send(embed=e) continue - msg = self._format_sonde_message(sonde_id, sonde) - await channel.send(msg) + embed = self._sonde_to_embed(sonde_id, sonde) + await channel.send(embed=embed) self._last_updates[guild.id] = now await asyncio.sleep(1) # small delay between guilds @@ -384,7 +385,7 @@ async def site(self, ctx, query: str): # Try exact match by station ID first site = sites_data.get(query) if site is not None: - await ctx.send(self._format_site_message(query, site)) + await ctx.send(embed=self._site_to_embed(query, site)) return # Search by station name (case-insensitive, substring) query_lower = query.lower() @@ -398,16 +399,17 @@ async def site(self, ctx, query: str): return if len(matches) == 1: sid, s = matches[0] - await ctx.send(self._format_site_message(sid, s)) + await ctx.send(embed=self._site_to_embed(sid, s)) return # Multiple matches: list them (up to 15) - lines = [f"**Multiple sites matching \"{query}\"** — use station ID for one:\n"] + desc_lines = [f"Multiple sites matching \"{query}\" — use station ID for one:"] for sid, s in sorted(matches, key=lambda x: (x[1].get("station_name") or ""))[:15]: name = s.get("station_name", "—") - lines.append(f"• `{sid}` — {name}") + desc_lines.append(f"`{sid}` — {name}") if len(matches) > 15: - lines.append(f"*… and {len(matches) - 15} more. Narrow your search.*") - await ctx.send("\n".join(lines)) + desc_lines.append(f"… and {len(matches) - 15} more. Narrow your search.") + e = discord.Embed(title=f"Sites matching '{query}'", description="\n".join(desc_lines), colour=0x55AAFF) + await ctx.send(embed=e) @sonde.command() async def history(self, ctx, serial: str, limit: int = 25): @@ -433,15 +435,19 @@ async def history(self, ctx, serial: str, limit: int = 25): return # Take last `limit` points points = telemetry[-limit:] - lines = [f"**Telemetry for {serial} (last {len(points)})**"] + desc_lines = [] for p in reversed(points): t = p.get("time") or p.get("timestamp") or p.get("ts") or "—" lat = p.get("lat") if p.get("lat") is not None else "—" lon = p.get("lon") if p.get("lon") is not None else "—" alt = p.get("alt") alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "—") - lines.append(f"{t} — Lat: {lat} | Lon: {lon} | Alt: {alt_str}") - await ctx.send("\n".join(lines)) + desc_lines.append(f"{t} — Lat: {lat} | Lon: {lon} | Alt: {alt_str}") + desc = "\n".join(desc_lines) + if len(desc) > 3500: + desc = desc[:3490] + "\n…output truncated…" + e = discord.Embed(title=f"Telemetry for {serial} (last {len(points)})", description=desc, colour=0x55AAFF) + await ctx.send(embed=e) @sonde.command() async def nearby(self, ctx, lat: float, lon: float, distance: float = 100.0): @@ -455,16 +461,17 @@ async def nearby(self, ctx, lat: float, lon: float, distance: float = 100.0): if not isinstance(data, dict) or not data: await ctx.send("No sondes found near that location.") return - lines = [f"**Sondes within {distance} of {lat},{lon}**"] + desc_lines = [f"Sondes within {distance} of {lat},{lon}"] for sid, s in sorted(data.items(), key=lambda x: x[0])[:25]: la = s.get("lat", "—") lo = s.get("lon", "—") alt = s.get("alt") alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else (str(alt) if alt is not None else "—") - lines.append(f"`{sid}` — Lat: {la} | Lon: {lo} | Alt: {alt_str}") + desc_lines.append(f"`{sid}` — Lat: {la} | Lon: {lo} | Alt: {alt_str}") if len(data) > 25: - lines.append(f"… and {len(data) - 25} more.") - await ctx.send("\n".join(lines)) + desc_lines.append(f"… and {len(data) - 25} more.") + e = discord.Embed(title="Nearby sondes", description="\n".join(desc_lines), colour=0x55AAFF) + await ctx.send(embed=e) @sonde.command() async def realtime(self, ctx): @@ -482,9 +489,10 @@ async def realtime(self, ctx): if isinstance(data, dict): url = data.get("url") or data.get("endpoint") or data.get("ws") if not url: - await ctx.send(f"Realtime endpoint: {data}") + await ctx.send(embed=discord.Embed(title="Realtime endpoint", description=str(data), colour=0xDD5555)) return - await ctx.send(f"Realtime MQTT-over-WebSocket endpoint: {url}") + e = discord.Embed(title="Realtime MQTT-over-WebSocket endpoint", description=url, colour=0x55AAFF) + await ctx.send(embed=e) @sonde.command() async def listeners(self, ctx): @@ -498,22 +506,48 @@ async def listeners(self, ctx): await ctx.send("No listener statistics available at the moment.") return # Present a brief summary of top-level keys - lines = ["**Listener statistics (summary)**"] - # If known fields exist, show them + e = discord.Embed(title="Listener statistics (summary)", colour=0x55AAFF) if isinstance(data, dict): - # common fields: uploaders, stations, listeners if "uploaders" in data: - lines.append(f"Uploaders: {len(data.get('uploaders') or [])}") + e.add_field(name="Uploaders", value=str(len(data.get("uploaders") or [])), inline=True) if "stations" in data: - lines.append(f"Stations: {len(data.get('stations') or [])}") + e.add_field(name="Stations", value=str(len(data.get("stations") or [])), inline=True) if "listeners" in data: - lines.append(f"Listeners: {len(data.get('listeners') or [])}") - # Fallback: show some top-level numeric values - for k, v in list(data.items())[:10]: + e.add_field(name="Listeners", value=str(len(data.get("listeners") or [])), inline=True) + # show some other top-level values + for k, v in list(data.items())[:8]: if k in ("uploaders", "stations", "listeners"): continue if isinstance(v, (int, float, str)): - lines.append(f"{k}: {v}") + e.add_field(name=k, value=str(v), inline=True) else: - lines.append(str(data)) - await ctx.send("\n".join(lines)) + e.description = str(data) + await ctx.send(embed=e) + + def _site_to_embed(self, site_id: str, site: dict) -> discord.Embed: + name = site.get("station_name", "—") + pos = site.get("position") + if isinstance(pos, (list, tuple)) and len(pos) >= 2: + lon, lat = pos[0], pos[1] + pos_str = f"Lat: {lat}, Lon: {lon}" + else: + pos_str = "—" + alt = site.get("alt") + alt_str = f"{alt} m" if alt is not None else "—" + rs = site.get("rs_types", []) + rs_str = ", ".join(str(r) for r in rs[:10]) if rs else "—" + if rs and len(rs) > 10: + rs_str += f" (+{len(rs) - 10} more)" + times = site.get("times", []) + times_str = ", ".join(str(t) for t in times[:6]) if times else "—" + if times and len(times) > 6: + times_str += f" (+{len(times) - 6} more)" + notes = site.get("notes", "").strip() + e = discord.Embed(title=f"{name} ({site_id})", colour=0x55AAFF) + e.add_field(name="Position", value=pos_str, inline=False) + e.add_field(name="Altitude", value=alt_str, inline=True) + e.add_field(name="Radiosonde types", value=rs_str, inline=True) + e.add_field(name="Launch times (UTC)", value=times_str, inline=False) + if notes: + e.add_field(name="Notes", value=notes[:300] + ("…" if len(notes) > 300 else ""), inline=False) + return e From e3b4b226f70e6f5e83df286807870cfd3598f00e Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:04:34 +0000 Subject: [PATCH 06/67] more --- radiosonde/info.json | 21 +++++++++++++++++++++ radiosonde/radiosonde.py | 8 ++++++++ 2 files changed, 29 insertions(+) create mode 100644 radiosonde/info.json diff --git a/radiosonde/info.json b/radiosonde/info.json new file mode 100644 index 0000000..cdb51f9 --- /dev/null +++ b/radiosonde/info.json @@ -0,0 +1,21 @@ +{ + "author": [ + "bencos167" + ], + "name": "Radiosonde", + "short": "Track radiosondes using the SondeHub API", + "description": "A Red Discord bot cog that tracks radiosondes using the SondeHub V2 API. Features include tracking specific sondes, querying by location, viewing telemetry history, and listening to live updates.", + "install_msg": "Thanks for installing Radiosonde! Use `[p]help sonde` to get started.", + "end_user_data_statement": "This cog does not store any end user data.", + "requirements": [ + "aiohttp" + ], + "tags": [ + "radiosonde", + "tracking", + "api", + "sondehub" + ], + "hidden": false, + "disabled": false +} \ No newline at end of file diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index 11dd4c3..6df84bb 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -4,6 +4,8 @@ import aiohttp import asyncio +__version__ = "1.0.0" + class Radiosonde(commands.Cog): """Track radiosondes using the SondeHub API.""" @@ -551,3 +553,9 @@ def _site_to_embed(self, site_id: str, site: dict) -> discord.Embed: if notes: e.add_field(name="Notes", value=notes[:300] + ("…" if len(notes) > 300 else ""), inline=False) return e + @sonde.command() + async def version(self, ctx): + """Show the cog version.""" + e = discord.Embed(title="Radiosonde Cog", description=f"Version {__version__}", colour=0x55AAFF) + e.add_field(name="API", value="SondeHub V2", inline=False) + await ctx.send(embed=e) \ No newline at end of file From 8a5571a427d2cfa5ce9e3b15ff9e6d35d102cf98 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:08:14 +0000 Subject: [PATCH 07/67] dashboard support v1 --- radiosonde/dashboard.py | 337 +++++++++++++++++++++++++++++++++++++++ radiosonde/radiosonde.py | 4 +- 2 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 radiosonde/dashboard.py diff --git a/radiosonde/dashboard.py b/radiosonde/dashboard.py new file mode 100644 index 0000000..bf5fe51 --- /dev/null +++ b/radiosonde/dashboard.py @@ -0,0 +1,337 @@ +import typing +import discord +import wtforms +from redbot.core import commands +import datetime + + +def dashboard_page(*args, **kwargs): + """Decorator for dashboard pages.""" + def decorator(func): + func.__dashboard_decorator_params__ = (args, kwargs) + return func + return decorator + + +class DashboardIntegration: + """Dashboard integration for Radiosonde cog.""" + + bot: commands.Bot + + @commands.Cog.listener() + async def on_dashboard_cog_add(self, dashboard_cog: commands.Cog) -> None: + """Register dashboard pages when dashboard cog loads.""" + dashboard_cog.rpc.third_parties_handler.add_third_party(self) + + @dashboard_page(name=None, description="Radiosonde Stats Page", methods=("GET",)) + async def dashboard_stats(self, **kwargs) -> typing.Dict[str, typing.Any]: + """Show sonde tracking statistics.""" + cog = getattr(self, "_radiosonde_cog", None) + if not cog: + return {"status": 0, "web_content": {"source": "

Radiosonde cog not loaded.

"}} + + try: + # Fetch latest sondes to show active tracking + sondes_data, error = await cog.fetch_sondes() + total_sondes = len(sondes_data) if sondes_data else 0 + + # Count tracked sondes across all guilds + tracked_total = 0 + guild_count = 0 + for guild in self.bot.guilds: + tracked = await cog.config.guild(guild).tracked_sondes() + if tracked: + tracked_total += len(tracked) + guild_count += 1 + + stats_html = f""" +
+

📡 Radiosonde Stats

+

Real-time radiosonde tracking statistics from SondeHub V2 API.

+ +
+
+

📊 API Statistics

+
    +
  • Active Sondes: {total_sondes:,}
  • +
  • Sondes Tracked: {tracked_total}
  • +
  • Tracking Guilds: {guild_count}
  • +
+
+ +
+

🌍 API Information

+
    +
  • API Host: api.v2.sondehub.org
  • +
  • Status: {'✅ Online' if not error else '❌ Offline'}
  • +
  • Error: {error if error else 'None'}
  • +
+
+
+
+ """ + + return { + "status": 0, + "web_content": { + "source": stats_html + } + } + except Exception as e: + return { + "status": 1, + "web_content": { + "source": f"

Error loading statistics: {str(e)}

" + }, + "notifications": [{"message": f"Error loading stats: {str(e)}", "category": "error"}] + } + + @dashboard_page(name="tracked", description="Tracked Sondes Status", methods=("GET",), context_ids=["guild_id"]) + async def dashboard_tracked_sondes(self, guild: discord.Guild, **kwargs) -> typing.Dict[str, typing.Any]: + """Show status of tracked sondes for a guild.""" + cog = getattr(self, "_radiosonde_cog", None) + if not cog: + return {"status": 0, "web_content": {"source": "

Radiosonde cog not loaded.

"}} + + try: + tracked = await cog.config.guild(guild).tracked_sondes() + if not tracked: + return { + "status": 0, + "web_content": { + "source": """ +
+

Tracked Sondes

+

No sondes are currently tracked in this guild.

+
+ """ + } + } + + sondes_data, error = await cog.fetch_sondes() + if error or not sondes_data: + return { + "status": 1, + "web_content": { + "source": f"

Could not fetch sonde data: {error}

" + } + } + + # Build sonde list HTML + sondes_html = '
' + sondes_html += '

Tracked Sondes Status

' + sondes_html += '
' + + for sonde_id in tracked: + sonde = sondes_data.get(sonde_id) + if not sonde: + sondes_html += f''' +
+ {sonde_id} +

No current data (not in latest API)

+
+ ''' + else: + lat = sonde.get("lat", "—") + lon = sonde.get("lon", "—") + alt = sonde.get("alt") + alt_str = f"{alt:.1f} m" if isinstance(alt, (int, float)) else "—" + + vel_h = sonde.get("vel_h") + vel_v = sonde.get("vel_v") + if isinstance(vel_h, (int, float)) and isinstance(vel_v, (int, float)): + speed = (vel_h ** 2 + vel_v ** 2) ** 0.5 + elif isinstance(vel_h, (int, float)): + speed = vel_h + else: + speed = None + speed_str = f"{speed:.1f} m/s" if speed is not None else "—" + + temp = sonde.get("temp", "—") + uploader = sonde.get("uploader_callsign") or sonde.get("uploader") or "—" + + sondes_html += f''' +
+ {sonde_id} +

+ Position: {lat}, {lon} | Altitude: {alt_str} | Speed: {speed_str}
+ Temp: {temp}°C | Uploader: {uploader} +

+
+ ''' + + sondes_html += '
' + return { + "status": 0, + "web_content": { + "source": sondes_html + } + } + + except Exception as e: + return { + "status": 1, + "web_content": { + "source": f"

Error loading tracked sondes: {str(e)}

" + }, + "notifications": [{"message": f"Error: {str(e)}", "category": "error"}] + } + + @dashboard_page(name="settings", description="Guild Settings", methods=("GET", "POST"), context_ids=["guild_id"]) + async def dashboard_guild_settings(self, guild: discord.Guild, **kwargs) -> typing.Dict[str, typing.Any]: + """Manage guild-specific radiosonde settings.""" + cog = getattr(self, "_radiosonde_cog", None) + if not cog: + return {"status": 0, "web_content": {"source": "

Radiosonde cog not loaded.

"}} + + config = cog.config.guild(guild) + try: + update_channel_id = await config.update_channel() + update_interval = await config.update_interval() + except Exception as e: + return { + "status": 1, + "web_content": {"source": f"

Error loading config: {e}

"}, + "notifications": [{"message": f"Error loading config: {e}", "category": "error"}] + } + + # Get channel name for display + update_channel_name = "None" + if update_channel_id: + channel = guild.get_channel(update_channel_id) + update_channel_name = channel.name if channel else f"Unknown Channel ({update_channel_id})" + + # WTForms form definition + class SettingsForm(kwargs["Form"]): + def __init__(self): + super().__init__(prefix="settings_") + update_channel = wtforms.StringField( + "Update Channel ID", + render_kw={"class": "form-field", "placeholder": "Leave empty to disable updates"} + ) + update_interval = wtforms.IntegerField( + "Update Interval (seconds)", + render_kw={"class": "form-field", "min": "30", "max": "3600"} + ) + submit = wtforms.SubmitField("Save Settings", render_kw={"class": "form-submit"}) + + settings_form = SettingsForm() + result_html = "" + + # Handle form submission + if settings_form.validate_on_submit(): + try: + channel_val = int(settings_form.update_channel.data) if settings_form.update_channel.data else None + interval_val = settings_form.update_interval.data or 300 + + if interval_val < 30 or interval_val > 3600: + result_html = ''' +
+ Error: Interval must be between 30 and 3600 seconds. +
+ ''' + elif channel_val and not guild.get_channel(channel_val): + result_html = ''' +
+ Error: Channel not found. Please enter a valid channel ID. +
+ ''' + else: + await config.update_channel.set(channel_val) + await config.update_interval.set(interval_val) + + if channel_val: + channel = guild.get_channel(channel_val) + update_channel_name = channel.name if channel else f"Channel {channel_val}" + else: + update_channel_name = "None" + + result_html = ''' +
+ Success: Settings updated successfully! +
+ ''' + except ValueError: + result_html = ''' +
+ Error: Invalid channel ID. Please enter a valid numeric ID. +
+ ''' + except Exception as e: + result_html = f''' +
+ Error: {str(e)} +
+ ''' + + # Populate form + settings_form.update_channel.data = str(update_channel_id or "") + settings_form.update_interval.data = update_interval + + return { + "status": 0, + "web_content": { + "source": """ +
+

Radiosonde Guild Settings

+

Configure sonde update settings for this guild.

+ +
+

Current Settings:

+
+
    +
  • Update Channel: {{ update_channel_name }}
  • +
  • Update Interval: {{ update_interval }} seconds
  • +
+
+
+ +
+

Update Settings:

+
+ +
+ {{ settings_form|safe }} +
+
+
+ + {{ result_html|safe }} +
+ """, + "settings_form": settings_form, + "result_html": result_html, + "update_channel_name": update_channel_name, + "update_interval": update_interval, + }, + } diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index 6df84bb..ea94733 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -3,14 +3,16 @@ from redbot.core import commands, Config, checks import aiohttp import asyncio +from .dashboard import DashboardIntegration __version__ = "1.0.0" -class Radiosonde(commands.Cog): +class Radiosonde(commands.Cog, DashboardIntegration): """Track radiosondes using the SondeHub API.""" def __init__(self, bot): self.bot = bot + self._radiosonde_cog = self # For dashboard integration self.config = Config.get_conf( self, identifier=492089091320446976, force_registration=True ) From 8864886faec50c2ede717ce55fb941e81148db8e Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:08:26 +0000 Subject: [PATCH 08/67] bump cog version --- radiosonde/radiosonde.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index ea94733..2632753 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -5,7 +5,7 @@ import asyncio from .dashboard import DashboardIntegration -__version__ = "1.0.0" +__version__ = "1.0.1" class Radiosonde(commands.Cog, DashboardIntegration): """Track radiosondes using the SondeHub API.""" From ae05cc224270fd733b1c4378973cfcdf89c2e04b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:12:38 +0000 Subject: [PATCH 09/67] Update radiosonde.py --- radiosonde/radiosonde.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index 2632753..bc3d15e 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -7,7 +7,7 @@ __version__ = "1.0.1" -class Radiosonde(commands.Cog, DashboardIntegration): +class Radiosonde(DashboardIntegration, commands.Cog): """Track radiosondes using the SondeHub API.""" def __init__(self, bot): From 460ca414023fbc1d2b6ac21cce504e4bf3600079 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:20:04 +0000 Subject: [PATCH 10/67] dashboard support works --- radiosonde/dashboard.py | 6 +++--- radiosonde/radiosonde.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/radiosonde/dashboard.py b/radiosonde/dashboard.py index bf5fe51..00d4540 100644 --- a/radiosonde/dashboard.py +++ b/radiosonde/dashboard.py @@ -211,7 +211,7 @@ def __init__(self): ) update_interval = wtforms.IntegerField( "Update Interval (seconds)", - render_kw={"class": "form-field", "min": "30", "max": "3600"} + render_kw={"class": "form-field", "min": "30", "max": "86400"} ) submit = wtforms.SubmitField("Save Settings", render_kw={"class": "form-submit"}) @@ -224,10 +224,10 @@ def __init__(self): channel_val = int(settings_form.update_channel.data) if settings_form.update_channel.data else None interval_val = settings_form.update_interval.data or 300 - if interval_val < 30 or interval_val > 3600: + if interval_val < 30 or interval_val > 86400: result_html = '''
- Error: Interval must be between 30 and 3600 seconds. + Error: Interval must be between 30 seconds and 24 hours (86400 seconds).
''' elif channel_val and not guild.get_channel(channel_val): diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index bc3d15e..ae8c446 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -5,7 +5,7 @@ import asyncio from .dashboard import DashboardIntegration -__version__ = "1.0.1" +__version__ = "1.0.2" class Radiosonde(DashboardIntegration, commands.Cog): """Track radiosondes using the SondeHub API.""" From 4e92129d912f8f03e82f69374426b35f39fc11fb Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:21:24 +0000 Subject: [PATCH 11/67] Update radiosonde.py --- radiosonde/radiosonde.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/radiosonde/radiosonde.py b/radiosonde/radiosonde.py index ae8c446..fe60be7 100644 --- a/radiosonde/radiosonde.py +++ b/radiosonde/radiosonde.py @@ -5,7 +5,7 @@ import asyncio from .dashboard import DashboardIntegration -__version__ = "1.0.2" +__version__ = "1.0.3" class Radiosonde(DashboardIntegration, commands.Cog): """Track radiosondes using the SondeHub API.""" @@ -254,6 +254,34 @@ async def list(self, ctx): return await ctx.send("Tracked sondes: " + ", ".join(tracked)) + @sonde.command() + async def settings(self, ctx): + """Show current guild settings.""" + update_channel_id = await self.config.guild(ctx.guild).update_channel() + update_interval = await self.config.guild(ctx.guild).update_interval() + + channel_name = "Not set" + if update_channel_id: + channel = ctx.guild.get_channel(update_channel_id) + channel_name = channel.mention if channel else f"Unknown (ID: {update_channel_id})" + + # Convert seconds to human-readable format + def format_interval(seconds): + if seconds < 60: + return f"{seconds}s" + elif seconds < 3600: + return f"{seconds // 60}m" + elif seconds < 86400: + return f"{seconds // 3600}h" + else: + return f"{seconds // 86400}d" + + e = discord.Embed(title="Radiosonde Guild Settings", colour=0x55AAFF) + e.add_field(name="Update Channel", value=channel_name, inline=False) + e.add_field(name="Update Interval", value=format_interval(update_interval), inline=True) + e.add_field(name="Interval (seconds)", value=str(update_interval), inline=True) + await ctx.send(embed=e) + @sonde.command() async def status(self, ctx): """List current status of all tracked sondes (lat, lon, alt, speed).""" From 7ac77d32fb6cffcae88e8a350f3a66ebb3aab9e1 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:53:04 +0000 Subject: [PATCH 12/67] tips cog --- tips/__init__.py | 5 +++ tips/info.json | 17 +++++++++ tips/tips.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 tips/__init__.py create mode 100644 tips/info.json create mode 100644 tips/tips.py diff --git a/tips/__init__.py b/tips/__init__.py new file mode 100644 index 0000000..9091e16 --- /dev/null +++ b/tips/__init__.py @@ -0,0 +1,5 @@ +from .tips import Tips + + +async def setup(bot): + await bot.add_cog(Tips(bot)) diff --git a/tips/info.json b/tips/info.json new file mode 100644 index 0000000..961bf81 --- /dev/null +++ b/tips/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "bencos18" + ], + "install_msg": "Thank you for installing the tips cog. Use `[p]help Tips` to see available commands. For support, message me on Discord (bencos18) or open a GitHub issue.", + "short": "Tips cog for redbot to show random tips when a command is ran.", + "description": "Tips cog for redbot to show random tips when a command is ran..", + "requirements": [], + "tags": [ + "server", + "tools", + "tips" + ], + "min_bot_version": "3.4.0", + "end_user_data_statement": "", + "type": "COG" +} \ No newline at end of file diff --git a/tips/tips.py b/tips/tips.py new file mode 100644 index 0000000..21bf6a1 --- /dev/null +++ b/tips/tips.py @@ -0,0 +1,91 @@ +import discord +from redbot.core import commands, checks +import asyncio +import random + +class Tips(commands.Cog): + """A cog that displays random tips at intervals.""" + + def __init__(self, bot): + self.bot = bot + self.tips = [ + "Tip 1: Use `help` command to see all available commands!", + "Tip 2: Check the documentation for more information.", + "Tip 3: Use reactions to interact with bot messages.", + "Tip 4: Commands are case-insensitive.", + "Tip 5: You can use prefixes to customize your experience.", + ] + self.last_tip_time = {} + self.cooldown = 60 + self.tip_color = discord.Color.blue() + self.tip_title = "💡 Random Tip" + + @commands.command() + async def tip(self, ctx): + """Get a random tip.""" + user_id = ctx.author.id + current_time = asyncio.get_event_loop().time() + + # Check if user has requested a tip recently (cooldown: 60 seconds) + if user_id in self.last_tip_time: + if current_time - self.last_tip_time[user_id] < self.cooldown: + await ctx.send("You can only get a tip once per minute!") + return + + self.last_tip_time[user_id] = current_time + random_tip = random.choice(self.tips) + + embed = discord.Embed( + title="💡 Random Tip", + description=random_tip, + color=discord.Color.blue() + ) + await ctx.send(embed=embed) + + @checks.is_owner() + @commands.command() + async def addtip(self, ctx, *, tip: str): + """Add a new tip to the list.""" + self.tips.append(tip) + await ctx.send(f"✅ Tip added! Total tips: {len(self.tips)}") + + @checks.is_owner() + @commands.command() + async def removetip(self, ctx, index: int): + """Remove a tip by index.""" + if 0 <= index < len(self.tips): + removed = self.tips.pop(index) + await ctx.send(f"✅ Tip removed: {removed}") + else: + await ctx.send("Invalid tip index.") + + @checks.is_owner() + @commands.command() + async def tipconfig(self, ctx, setting: str, *, value: str): + """Configure tip settings (cooldown, color, title).""" + if setting.lower() == "cooldown": + try: + self.cooldown = int(value) + await ctx.send(f"✅ Cooldown set to {value} seconds.") + except ValueError: + await ctx.send("Cooldown must be a number.") + elif setting.lower() == "color": + color_map = { + "blue": discord.Color.blue(), + "red": discord.Color.red(), + "green": discord.Color.green(), + } + if value.lower() in color_map: + self.tip_color = color_map[value.lower()] + await ctx.send(f"✅ Color set to {value}.") + else: + await ctx.send("Invalid color.") + elif setting.lower() == "title": + self.tip_title = value + await ctx.send(f"✅ Title set to {value}.") + else: + await ctx.send("Invalid setting. Use: cooldown, color, or title.") + + +async def setup(bot): + await bot.add_cog(Tips(bot)) \ No newline at end of file From 28e1d6935e1f3e8f27d15cb3accd52b622249ea0 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:57:39 +0000 Subject: [PATCH 13/67] Update tips.py --- tips/tips.py | 128 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 28 deletions(-) diff --git a/tips/tips.py b/tips/tips.py index 21bf6a1..f4b2c6e 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -3,6 +3,91 @@ import asyncio import random + +class TipSettingsView(discord.ui.View): + def __init__(self, cog, author_id): + super().__init__(timeout=120) + self.cog = cog + self.author_id = author_id + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user.id == self.author_id + + def build_embed(self) -> discord.Embed: + e = discord.Embed( + title=self.cog.tip_title, + description="Configure tip settings using the buttons below.", + color=self.cog.tip_color, + ) + e.add_field(name="Cooldown (s)", value=str(self.cog.cooldown), inline=False) + e.add_field(name="Color", value=str(self.cog.tip_color), inline=False) + e.add_field(name="Total tips", value=str(len(self.cog.tips)), inline=False) + return e + + @discord.ui.button(label="Cooldown", style=discord.ButtonStyle.primary) + async def cooldown_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message("Please type the new cooldown in seconds.", ephemeral=True) + + def check(m: discord.Message): + return m.author.id == self.author_id and m.channel == interaction.channel + + try: + msg = await self.cog.bot.wait_for("message", check=check, timeout=60) + try: + self.cog.cooldown = int(msg.content) + await interaction.followup.send(f"✅ Cooldown set to {msg.content} seconds.", ephemeral=True) + if interaction.message is not None: + await interaction.message.edit(embed=self.build_embed()) + except ValueError: + await interaction.followup.send("Cooldown must be a number.", ephemeral=True) + except asyncio.TimeoutError: + await interaction.followup.send("Timed out waiting for input.", ephemeral=True) + + @discord.ui.button(label="Color", style=discord.ButtonStyle.secondary) + async def color_button(self, interaction: discord.Interaction, button: discord.ui.Button): + color_map = {"blue": discord.Color.blue(), "red": discord.Color.red(), "green": discord.Color.green()} + await interaction.response.send_message( + "Please type a color name (blue, red, green).", ephemeral=True + ) + + def check(m: discord.Message): + return m.author.id == self.author_id and m.channel == interaction.channel + + try: + msg = await self.cog.bot.wait_for("message", check=check, timeout=60) + val = msg.content.lower() + if val in color_map: + self.cog.tip_color = color_map[val] + await interaction.followup.send(f"✅ Color set to {val}.", ephemeral=True) + if interaction.message is not None: + await interaction.message.edit(embed=self.build_embed()) + else: + await interaction.followup.send("Invalid color.", ephemeral=True) + except asyncio.TimeoutError: + await interaction.followup.send("Timed out waiting for input.", ephemeral=True) + + @discord.ui.button(label="Title", style=discord.ButtonStyle.success) + async def title_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message("Please type the new title for tips.", ephemeral=True) + + def check(m: discord.Message): + return m.author.id == self.author_id and m.channel == interaction.channel + + try: + msg = await self.cog.bot.wait_for("message", check=check, timeout=120) + self.cog.tip_title = msg.content + await interaction.followup.send(f"✅ Title set to {msg.content}.", ephemeral=True) + if interaction.message is not None: + await interaction.message.edit(embed=self.build_embed()) + except asyncio.TimeoutError: + await interaction.followup.send("Timed out waiting for input.", ephemeral=True) + + @discord.ui.button(label="Close", style=discord.ButtonStyle.danger) + async def close_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.defer() + if interaction.message is not None: + await interaction.message.delete() + class Tips(commands.Cog): """A cog that displays random tips at intervals.""" @@ -10,10 +95,9 @@ def __init__(self, bot): self.bot = bot self.tips = [ "Tip 1: Use `help` command to see all available commands!", - "Tip 2: Check the documentation for more information.", - "Tip 3: Use reactions to interact with bot messages.", - "Tip 4: Commands are case-insensitive.", - "Tip 5: You can use prefixes to customize your experience.", + "Tip 2: Use reactions or buttons to interact with bot messages.", + "Tip 3: Commands are case sensitive.", + "Tip 4: You can use prefixes to customise your experience.", ] self.last_tip_time = {} self.cooldown = 60 @@ -61,30 +145,18 @@ async def removetip(self, ctx, index: int): @checks.is_owner() @commands.command() - async def tipconfig(self, ctx, setting: str, *, value: str): - """Configure tip settings (cooldown, color, title).""" - if setting.lower() == "cooldown": - try: - self.cooldown = int(value) - await ctx.send(f"✅ Cooldown set to {value} seconds.") - except ValueError: - await ctx.send("Cooldown must be a number.") - elif setting.lower() == "color": - color_map = { - "blue": discord.Color.blue(), - "red": discord.Color.red(), - "green": discord.Color.green(), - } - if value.lower() in color_map: - self.tip_color = color_map[value.lower()] - await ctx.send(f"✅ Color set to {value}.") - else: - await ctx.send("Invalid color.") - elif setting.lower() == "title": - self.tip_title = value - await ctx.send(f"✅ Title set to {value}.") - else: - await ctx.send("Invalid setting. Use: cooldown, color, or title.") + async def tipconfig(self, ctx): + """Open a button-style settings menu for tips.""" + view = TipSettingsView(self, ctx.author.id) + embed = discord.Embed( + title=self.tip_title, + description="Configure tip settings using the buttons below.", + color=self.tip_color, + ) + embed.add_field(name="Cooldown (s)", value=str(self.cooldown), inline=False) + embed.add_field(name="Color", value=str(self.tip_color), inline=False) + embed.add_field(name="Total tips", value=str(len(self.tips)), inline=False) + await ctx.send(embed=embed, view=view) async def setup(bot): From 4601f63ae51380978d8d9d2fe0cd127b156a3354 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:01:29 +0000 Subject: [PATCH 14/67] config support --- tips/tips.py | 52 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/tips/tips.py b/tips/tips.py index f4b2c6e..b30d9ae 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -1,5 +1,5 @@ import discord -from redbot.core import commands, checks +from redbot.core import commands, checks, Config import asyncio import random @@ -35,6 +35,7 @@ def check(m: discord.Message): msg = await self.cog.bot.wait_for("message", check=check, timeout=60) try: self.cog.cooldown = int(msg.content) + await self.cog.config.cooldown.set(self.cog.cooldown) await interaction.followup.send(f"✅ Cooldown set to {msg.content} seconds.", ephemeral=True) if interaction.message is not None: await interaction.message.edit(embed=self.build_embed()) @@ -58,6 +59,7 @@ def check(m: discord.Message): val = msg.content.lower() if val in color_map: self.cog.tip_color = color_map[val] + await self.cog.config.tip_color.set(val) await interaction.followup.send(f"✅ Color set to {val}.", ephemeral=True) if interaction.message is not None: await interaction.message.edit(embed=self.build_embed()) @@ -76,6 +78,7 @@ def check(m: discord.Message): try: msg = await self.cog.bot.wait_for("message", check=check, timeout=120) self.cog.tip_title = msg.content + await self.cog.config.tip_title.set(msg.content) await interaction.followup.send(f"✅ Title set to {msg.content}.", ephemeral=True) if interaction.message is not None: await interaction.message.edit(embed=self.build_embed()) @@ -96,10 +99,21 @@ def __init__(self, bot): self.tips = [ "Tip 1: Use `help` command to see all available commands!", "Tip 2: Use reactions or buttons to interact with bot messages.", - "Tip 3: Commands are case sensitive.", - "Tip 4: You can use prefixes to customise your experience.", + "Tip 3: Commands are case-insensitive.", + "Tip 4: You can use prefixes to customize your experience.", ] self.last_tip_time = {} + # Config setup + self.config = Config.get_conf(self, identifier=492089091320446976) + default_global = { + "cooldown": 60, + "tip_color": "blue", + "tip_title": "💡 Random Tip", + "tips": self.tips, + } + self.config.register_global(**default_global) + + # Runtime values (will be loaded from config in cog_load) self.cooldown = 60 self.tip_color = discord.Color.blue() self.tip_title = "💡 Random Tip" @@ -107,6 +121,16 @@ def __init__(self, bot): @commands.command() async def tip(self, ctx): """Get a random tip.""" + # Refresh runtime values from config + try: + self.cooldown = await self.config.cooldown() + color_name = await self.config.tip_color() + color_map = {"blue": discord.Color.blue(), "red": discord.Color.red(), "green": discord.Color.green()} + self.tip_color = color_map.get(color_name, discord.Color.blue()) + self.tip_title = await self.config.tip_title() + self.tips = await self.config.tips() + except Exception: + pass user_id = ctx.author.id current_time = asyncio.get_event_loop().time() @@ -117,12 +141,12 @@ async def tip(self, ctx): return self.last_tip_time[user_id] = current_time - random_tip = random.choice(self.tips) - + random_tip = random.choice(self.tips) if self.tips else "No tips available." + embed = discord.Embed( - title="💡 Random Tip", + title=self.tip_title, description=random_tip, - color=discord.Color.blue() + color=self.tip_color, ) await ctx.send(embed=embed) @@ -131,6 +155,7 @@ async def tip(self, ctx): async def addtip(self, ctx, *, tip: str): """Add a new tip to the list.""" self.tips.append(tip) + await self.config.tips.set(self.tips) await ctx.send(f"✅ Tip added! Total tips: {len(self.tips)}") @checks.is_owner() @@ -139,6 +164,7 @@ async def removetip(self, ctx, index: int): """Remove a tip by index.""" if 0 <= index < len(self.tips): removed = self.tips.pop(index) + await self.config.tips.set(self.tips) await ctx.send(f"✅ Tip removed: {removed}") else: await ctx.send("Invalid tip index.") @@ -158,6 +184,18 @@ async def tipconfig(self, ctx): embed.add_field(name="Total tips", value=str(len(self.tips)), inline=False) await ctx.send(embed=embed, view=view) + async def cog_load(self) -> None: + """Load values from config into runtime attributes.""" + try: + self.cooldown = await self.config.cooldown() + color_name = await self.config.tip_color() + color_map = {"blue": discord.Color.blue(), "red": discord.Color.red(), "green": discord.Color.green()} + self.tip_color = color_map.get(color_name, discord.Color.blue()) + self.tip_title = await self.config.tip_title() + self.tips = await self.config.tips() + except Exception: + pass + async def setup(bot): await bot.add_cog(Tips(bot)) \ No newline at end of file From 2ec62af589f2596879ea0deeaa4f3e6d6ca1dfb0 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:04:32 +0000 Subject: [PATCH 15/67] per user and per server cooldowns --- tips/docs.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++ tips/tips.py | 84 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 tips/docs.md diff --git a/tips/docs.md b/tips/docs.md new file mode 100644 index 0000000..5a5036a --- /dev/null +++ b/tips/docs.md @@ -0,0 +1,92 @@ +# Tips Cog + +A small Red cog that posts random tips and provides an interactive button-based settings menu for the bot owner. + +**Overview** + +- Posts a random tip with the `tip` command. +- Owner-only commands let you add/remove tips and open a button-based settings UI. +- Settings (cooldown, color, title, tips list) are persisted to Red `Config` (global scope), so they survive restarts. + +**Commands** + +- `tip` + - Usage: `[prefix]tip` + - Posts a random tip embed. Each user has a per-user cooldown (default 60s). + +- `addtip ` (owner-only) + - Usage: `[prefix]addtip This is a new tip.` + - Adds a tip to the saved tips list and persists it. + +- `removetip ` (owner-only) + - Usage: `[prefix]removetip 2` + - Removes the tip at the given zero-based index from the saved list. + +- `tipconfig` (owner-only) + - Usage: `[prefix]tipconfig` + - Opens an interactive embed with buttons for configuring cooldown, color, title, and closing the menu. + +**Button UI behaviour** + +- Only the user who invoked `tipconfig` may interact with the buttons. +- Buttons and flows: + - `Cooldown` (Primary): Bot asks (ephemeral) "Please type the new cooldown in seconds." Type a number in the same channel within 60 seconds. The value is validated and saved to config. + - `Color` (Secondary): Bot asks (ephemeral) "Please type a color name (blue, red, green)." Type one of the supported names within 60 seconds. The chosen color name is saved to config (the embed color updates). + - `Title` (Success): Bot asks (ephemeral) "Please type the new title for tips." Type the title within 120 seconds; it is saved to config and the embed updates. + - `Close` (Danger): Deletes the settings message. +- Timeouts: the view will time out after 120 seconds; individual prompts have the timeouts described above. + +**Config (global)** + +The cog uses Red `Config` with the following global keys (registered with defaults): + +- `cooldown` (int) + - Default: `60` + - Per-user cooldown in seconds between `tip` requests. + +- `tip_color` (str) + - Default: `"blue"` + - Stored as a string; the cog maps it to a `discord.Color` (supported names: `blue`, `red`, `green`). + +- `tip_title` (str) + - Default: `"💡 Random Tip"` + - The embed title used when posting tips. + +- `tips` (list[str]) + - Default: initial example tips included with the cog. + - The full list of tips the cog will choose from. + +Notes: +- All changes made via commands or the button UI are persisted immediately using `Config.set`. +- If you need additional color options, edit the cog to add more mappings in the color map. + +**Examples** + +- Add a tip (owner): + - `[p]addtip Remember to check pinned messages.` + +- Remove tip index 0 (owner): + - `[p]removetip 0` + +- Open the settings UI (owner): + - `[p]tipconfig` + - Click `Cooldown`, type `30` in the channel when prompted (ephemeral prompts are used for the request itself). + +**Troubleshooting** + +- Buttons don't respond: + - Confirm you are the user who ran `[p]tipconfig`. + - The view times out after 120s; re-run `[p]tipconfig` to re-open. + +- Config changes didn't persist: + - Confirm the bot has permission to write to its data store (Red handles this normally). + - Check that the cog is loaded and that no exceptions appear in the bot logs. + +**Extending** + +- Add more color names by updating the `color_map` in `tips/tips.py` and adding the corresponding `discord.Color` entries. +- Change permissions (for example allow server admins to use `tipconfig`) by replacing the owner-only check on the command with an appropriate Red permission decorator. + +--- + +File: `tips/tips.py` — this document documents the behavior of the interactive settings UI and the persistence keys used in Red `Config`. diff --git a/tips/tips.py b/tips/tips.py index b30d9ae..8e18cdd 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -2,6 +2,7 @@ from redbot.core import commands, checks, Config import asyncio import random +from typing import Optional class TipSettingsView(discord.ui.View): @@ -111,7 +112,11 @@ def __init__(self, bot): "tip_title": "💡 Random Tip", "tips": self.tips, } + default_guild = {"cooldown": None} + default_user = {"cooldown": None} self.config.register_global(**default_global) + self.config.register_guild(**default_guild) + self.config.register_user(**default_user) # Runtime values (will be loaded from config in cog_load) self.cooldown = 60 @@ -123,24 +128,26 @@ async def tip(self, ctx): """Get a random tip.""" # Refresh runtime values from config try: - self.cooldown = await self.config.cooldown() + # Refresh runtime values from config (global values and tips list) + self.tip_title = await self.config.tip_title() color_name = await self.config.tip_color() color_map = {"blue": discord.Color.blue(), "red": discord.Color.red(), "green": discord.Color.green()} self.tip_color = color_map.get(color_name, discord.Color.blue()) - self.tip_title = await self.config.tip_title() self.tips = await self.config.tips() except Exception: pass user_id = ctx.author.id current_time = asyncio.get_event_loop().time() + # Determine effective cooldown (user -> guild -> global) + effective_cd = await self._get_effective_cooldown(ctx.author, ctx.guild) + key = (user_id, ctx.guild.id if ctx.guild else None) - # Check if user has requested a tip recently (cooldown: 60 seconds) - if user_id in self.last_tip_time: - if current_time - self.last_tip_time[user_id] < self.cooldown: - await ctx.send("You can only get a tip once per minute!") - return + last = self.last_tip_time.get(key, 0) + if current_time - last < (effective_cd or 0): + await ctx.send(f"You can only get a tip once every {effective_cd} seconds.") + return - self.last_tip_time[user_id] = current_time + self.last_tip_time[key] = current_time random_tip = random.choice(self.tips) if self.tips else "No tips available." embed = discord.Embed( @@ -187,15 +194,72 @@ async def tipconfig(self, ctx): async def cog_load(self) -> None: """Load values from config into runtime attributes.""" try: - self.cooldown = await self.config.cooldown() + # Refresh runtime values from config + self.tip_title = await self.config.tip_title() color_name = await self.config.tip_color() color_map = {"blue": discord.Color.blue(), "red": discord.Color.red(), "green": discord.Color.green()} self.tip_color = color_map.get(color_name, discord.Color.blue()) - self.tip_title = await self.config.tip_title() self.tips = await self.config.tips() except Exception: pass + async def _get_effective_cooldown(self, user: discord.User, guild: Optional[discord.Guild]) -> int: + """Resolve cooldown with priority: user -> guild -> global.""" + try: + user_cd = await self.config.user(user).cooldown() + except Exception: + user_cd = None + if user_cd is not None: + return int(user_cd) + try: + if guild: + guild_cd = await self.config.guild(guild).cooldown() + else: + guild_cd = None + except Exception: + guild_cd = None + if guild_cd is not None: + return int(guild_cd) + try: + global_cd = await self.config.cooldown() + return int(global_cd) + except Exception: + return 0 + + @commands.command() + async def setmycooldown(self, ctx, seconds: int): + """Set a personal cooldown (affects only you across servers).""" + if seconds < 0: + await ctx.send("Cooldown cannot be negative.") + return + await self.config.user(ctx.author).cooldown.set(seconds) + await ctx.send(f"✅ Your personal tip cooldown is now {seconds} seconds.") + + @commands.command() + async def clearmycooldown(self, ctx): + """Clear your personal cooldown override.""" + await self.config.user(ctx.author).cooldown.set(None) + await ctx.send("✅ Your personal tip cooldown override has been cleared.") + + @commands.admin_or_permissions(manage_guild=True) + @commands.guild_only() + @commands.command() + async def setguildcooldown(self, ctx, seconds: int): + """Set a guild-wide cooldown (affects all users in this server).""" + if seconds < 0: + await ctx.send("Cooldown cannot be negative.") + return + await self.config.guild(ctx.guild).cooldown.set(seconds) + await ctx.send(f"✅ Server tip cooldown set to {seconds} seconds.") + + @commands.admin_or_permissions(manage_guild=True) + @commands.guild_only() + @commands.command() + async def clearguildcooldown(self, ctx): + """Clear the guild-wide cooldown override.""" + await self.config.guild(ctx.guild).cooldown.set(None) + await ctx.send("✅ Server tip cooldown override cleared.") + async def setup(bot): await bot.add_cog(Tips(bot)) \ No newline at end of file From 682c217d1d3a855cb921b39ff676beaae81abe6d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:06:06 +0000 Subject: [PATCH 16/67] Add auto-post-on-command listener Introduce an feature to post a tip whenever any command runs. Adds a global `post_on_command` setting (default true) and a guild-level `post_on_command` override (default None), plus an on_command listener that respects guild/global settings and existing cooldown logic. The listener avoids bot and self-cog recursion, enforces per-user/per-guild cooldowns using the existing last_tip_time store, and uses existing tip list/title/color to send an embed. Also registers the new guild config key. --- tips/tips.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tips/tips.py b/tips/tips.py index 8e18cdd..fafa844 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -111,8 +111,9 @@ def __init__(self, bot): "tip_color": "blue", "tip_title": "💡 Random Tip", "tips": self.tips, + "post_on_command": True, } - default_guild = {"cooldown": None} + default_guild = {"cooldown": None, "post_on_command": None} default_user = {"cooldown": None} self.config.register_global(**default_global) self.config.register_guild(**default_guild) @@ -157,6 +158,55 @@ async def tip(self, ctx): ) await ctx.send(embed=embed) + async def on_command(self, ctx): + """Listener: when any command is run, optionally post a tip to the same channel. + + Respects guild/global `post_on_command` setting and the same cooldown resolution. + """ + # Ignore commands from bots or from this cog to avoid recursion + if ctx.author.bot: + return + if ctx.cog and getattr(ctx.cog, "__class__", None) is not None and ctx.cog.qualified_name == getattr(self, "qualified_name", "Tips"): + return + + # Decide whether we should post for this context + should = await self._should_post_on_command(ctx.guild) + if not should: + return + + # Use the same cooldown rules and attempt to post + effective_cd = await self._get_effective_cooldown(ctx.author, ctx.guild) + user_id = ctx.author.id + current_time = asyncio.get_event_loop().time() + key = (user_id, ctx.guild.id if ctx.guild else None) + last = self.last_tip_time.get(key, 0) + if current_time - last < (effective_cd or 0): + return + + # Send a tip + random_tip = random.choice(self.tips) if self.tips else None + if not random_tip: + return + embed = discord.Embed(title=self.tip_title, description=random_tip, color=self.tip_color) + await ctx.channel.send(embed=embed) + self.last_tip_time[key] = current_time + + async def _should_post_on_command(self, guild: Optional[discord.Guild]) -> bool: + """Resolve whether to post a tip when a command runs (guild override -> global).""" + try: + if guild is not None: + guild_val = await self.config.guild(guild).post_on_command() + else: + guild_val = None + except Exception: + guild_val = None + if guild_val is not None: + return bool(guild_val) + try: + return bool(await self.config.post_on_command()) + except Exception: + return False + @checks.is_owner() @commands.command() async def addtip(self, ctx, *, tip: str): From 7ce5050e901b109a756d3e2c2081d7ec9b20ba2a Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:06:55 +0000 Subject: [PATCH 17/67] Update tips.py --- tips/tips.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tips/tips.py b/tips/tips.py index fafa844..03a3209 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -207,6 +207,22 @@ async def _should_post_on_command(self, guild: Optional[discord.Guild]) -> bool: except Exception: return False + @commands.admin_or_permissions(manage_guild=True) + @commands.guild_only() + @commands.command() + async def disablepostoncommand(self, ctx): + """Disable posting tips automatically when commands are run in this server.""" + await self.config.guild(ctx.guild).post_on_command.set(False) + await ctx.send("✅ Disabled automatic tip posts on commands for this server.") + + @commands.admin_or_permissions(manage_guild=True) + @commands.guild_only() + @commands.command() + async def enablepostoncommand(self, ctx): + """Enable posting tips automatically when commands are run in this server.""" + await self.config.guild(ctx.guild).post_on_command.set(True) + await ctx.send("✅ Enabled automatic tip posts on commands for this server.") + @checks.is_owner() @commands.command() async def addtip(self, ctx, *, tip: str): From 4b32df1476fc20168f7cbac843553353ae7ac174 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:12:33 +0000 Subject: [PATCH 18/67] fixes --- tips/docs.md | 5 ++--- tips/tips.py | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tips/docs.md b/tips/docs.md index 5a5036a..5c29c41 100644 --- a/tips/docs.md +++ b/tips/docs.md @@ -22,10 +22,9 @@ A small Red cog that posts random tips and provides an interactive button-based - Usage: `[prefix]removetip 2` - Removes the tip at the given zero-based index from the saved list. -- `tipconfig` (owner-only) - - Usage: `[prefix]tipconfig` + `tipset` (owner-only) + - Usage: `[prefix]tipset` - Opens an interactive embed with buttons for configuring cooldown, color, title, and closing the menu. - **Button UI behaviour** - Only the user who invoked `tipconfig` may interact with the buttons. diff --git a/tips/tips.py b/tips/tips.py index 03a3209..8be0d4f 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -158,6 +158,7 @@ async def tip(self, ctx): ) await ctx.send(embed=embed) + @commands.Cog.listener() async def on_command(self, ctx): """Listener: when any command is run, optionally post a tip to the same channel. @@ -244,7 +245,9 @@ async def removetip(self, ctx, index: int): @checks.is_owner() @commands.command() - async def tipconfig(self, ctx): + @checks.is_owner() + @commands.command(name="tipset") + async def tipset(self, ctx): """Open a button-style settings menu for tips.""" view = TipSettingsView(self, ctx.author.id) embed = discord.Embed( From 6c4059e767950b06ef5f46465298f594873efd68 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:14:23 +0000 Subject: [PATCH 19/67] bug fix --- tips/tips.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tips/tips.py b/tips/tips.py index 8be0d4f..fba415c 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -243,8 +243,6 @@ async def removetip(self, ctx, index: int): else: await ctx.send("Invalid tip index.") - @checks.is_owner() - @commands.command() @checks.is_owner() @commands.command(name="tipset") async def tipset(self, ctx): From 834e8f5e22bdd5e5eeb321ac78ed340fce917f0f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:19:29 +0000 Subject: [PATCH 20/67] Update tips.py --- tips/tips.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tips/tips.py b/tips/tips.py index fba415c..e0df3a7 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -111,7 +111,7 @@ def __init__(self, bot): "tip_color": "blue", "tip_title": "💡 Random Tip", "tips": self.tips, - "post_on_command": True, + "post_on_command": False, } default_guild = {"cooldown": None, "post_on_command": None} default_user = {"cooldown": None} @@ -151,12 +151,8 @@ async def tip(self, ctx): self.last_tip_time[key] = current_time random_tip = random.choice(self.tips) if self.tips else "No tips available." - embed = discord.Embed( - title=self.tip_title, - description=random_tip, - color=self.tip_color, - ) - await ctx.send(embed=embed) + # Always post compact plain-text tips + await ctx.send(f"💡 {random_tip}") @commands.Cog.listener() async def on_command(self, ctx): @@ -188,9 +184,18 @@ async def on_command(self, ctx): random_tip = random.choice(self.tips) if self.tips else None if not random_tip: return - embed = discord.Embed(title=self.tip_title, description=random_tip, color=self.tip_color) + + # Tiny tip marker: prefix a tip with '-#' to post a compact plain-text tip. + if isinstance(random_tip, str) and random_tip.startswith("-#"): + content = random_tip[2:].strip() + await ctx.channel.send(f"💡 {content}") + self.last_tip_time[key] = current_time + return + + embed = discord.Embed(description=random_tip, color=self.tip_color) await ctx.channel.send(embed=embed) self.last_tip_time[key] = current_time + self.last_tip_time[key] = current_time async def _should_post_on_command(self, guild: Optional[discord.Guild]) -> bool: """Resolve whether to post a tip when a command runs (guild override -> global).""" @@ -328,5 +333,3 @@ async def clearguildcooldown(self, ctx): await ctx.send("✅ Server tip cooldown override cleared.") -async def setup(bot): - await bot.add_cog(Tips(bot)) \ No newline at end of file From a3df03230667bb28e1906278850f73cabb337554 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:23:09 +0000 Subject: [PATCH 21/67] Update tips.py --- tips/tips.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tips/tips.py b/tips/tips.py index e0df3a7..1cc3bba 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -195,7 +195,6 @@ async def on_command(self, ctx): embed = discord.Embed(description=random_tip, color=self.tip_color) await ctx.channel.send(embed=embed) self.last_tip_time[key] = current_time - self.last_tip_time[key] = current_time async def _should_post_on_command(self, guild: Optional[discord.Guild]) -> bool: """Resolve whether to post a tip when a command runs (guild override -> global).""" @@ -333,3 +332,14 @@ async def clearguildcooldown(self, ctx): await ctx.send("✅ Server tip cooldown override cleared.") +@checks.is_owner() +@commands.command() +async def listtips(self, ctx): + """List all current tips stored in config.""" + tips = await self.config.tips() + if not tips: + await ctx.send("No tips stored.") + return + + out = "\n".join(f"{i}: {t}" for i, t in enumerate(tips)) + await ctx.send(f"```{out}```") From 097da0c9313cbc62c0c508a3ca3db07fbc123726 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:25:06 +0000 Subject: [PATCH 22/67] I hate indents --- tips/tips.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tips/tips.py b/tips/tips.py index 1cc3bba..8d2d65a 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -332,14 +332,14 @@ async def clearguildcooldown(self, ctx): await ctx.send("✅ Server tip cooldown override cleared.") -@checks.is_owner() -@commands.command() -async def listtips(self, ctx): - """List all current tips stored in config.""" - tips = await self.config.tips() - if not tips: - await ctx.send("No tips stored.") - return - - out = "\n".join(f"{i}: {t}" for i, t in enumerate(tips)) - await ctx.send(f"```{out}```") + @checks.is_owner() + @commands.command() + async def listtips(self, ctx): + """List all current tips stored in config.""" + tips = await self.config.tips() + if not tips: + await ctx.send("No tips stored.") + return + + out = "\n".join(f"{i}: {t}" for i, t in enumerate(tips)) + await ctx.send(f"```{out}```") \ No newline at end of file From 97ba8988dd822dcd2e6b3aec4fb98330f54f3b9c Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:27:34 +0000 Subject: [PATCH 23/67] Update tips.py --- tips/tips.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tips/tips.py b/tips/tips.py index 8d2d65a..c737a14 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -342,4 +342,19 @@ async def listtips(self, ctx): return out = "\n".join(f"{i}: {t}" for i, t in enumerate(tips)) - await ctx.send(f"```{out}```") \ No newline at end of file + await ctx.send(f"```{out}```") + + +async def _force_merge_defaults(self): + tips = await self.config.tips() + changed = False + + for tip in DEFAULT_TIPS: + if tip not in tips: + tips.append(tip) + changed = True + + if changed: + await self.config.tips.set(tips) + + self.tips = tips From c76c32f31831d1bf6fb08aafb11b48e5879aeb14 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:08:14 +0000 Subject: [PATCH 24/67] Update tips.py --- tips/tips.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/tips/tips.py b/tips/tips.py index c737a14..3ff8a93 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -263,7 +263,7 @@ async def tipset(self, ctx): await ctx.send(embed=embed, view=view) async def cog_load(self) -> None: - """Load values from config into runtime attributes.""" + """Load values from config into runtime attributes and merge defaults.""" try: # Refresh runtime values from config self.tip_title = await self.config.tip_title() @@ -271,9 +271,27 @@ async def cog_load(self) -> None: color_map = {"blue": discord.Color.blue(), "red": discord.Color.red(), "green": discord.Color.green()} self.tip_color = color_map.get(color_name, discord.Color.blue()) self.tips = await self.config.tips() + + # Merge new defaults safely + await self._force_merge_defaults() except Exception: pass + async def _force_merge_defaults(self): + """Add any missing default tips to the config without overwriting existing ones.""" + tips = await self.config.tips() + changed = False + + for tip in self.tips: + if tip not in tips: + tips.append(tip) + changed = True + + if changed: + await self.config.tips.set(tips) + + self.tips = tips + async def _get_effective_cooldown(self, user: discord.User, guild: Optional[discord.Guild]) -> int: """Resolve cooldown with priority: user -> guild -> global.""" try: @@ -297,6 +315,7 @@ async def _get_effective_cooldown(self, user: discord.User, guild: Optional[disc except Exception: return 0 + @commands.command() async def setmycooldown(self, ctx, seconds: int): """Set a personal cooldown (affects only you across servers).""" @@ -342,19 +361,4 @@ async def listtips(self, ctx): return out = "\n".join(f"{i}: {t}" for i, t in enumerate(tips)) - await ctx.send(f"```{out}```") - - -async def _force_merge_defaults(self): - tips = await self.config.tips() - changed = False - - for tip in DEFAULT_TIPS: - if tip not in tips: - tips.append(tip) - changed = True - - if changed: - await self.config.tips.set(tips) - - self.tips = tips + await ctx.send(f"```{out}```") \ No newline at end of file From f02f76c5a45abc63cdd23bde0779e715e7f6f0a0 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:12:04 +0000 Subject: [PATCH 25/67] remove changed tips if deleted from default tips --- tips/tips.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tips/tips.py b/tips/tips.py index 3ff8a93..e660231 100644 --- a/tips/tips.py +++ b/tips/tips.py @@ -100,8 +100,8 @@ def __init__(self, bot): self.tips = [ "Tip 1: Use `help` command to see all available commands!", "Tip 2: Use reactions or buttons to interact with bot messages.", - "Tip 3: Commands are case-insensitive.", - "Tip 4: You can use prefixes to customize your experience.", + "Tip 3: Commands are case sensitive.", + "Tip 4: You can use prefixes to customize your experience ", ] self.last_tip_time = {} # Config setup @@ -278,19 +278,26 @@ async def cog_load(self) -> None: pass async def _force_merge_defaults(self): - """Add any missing default tips to the config without overwriting existing ones.""" - tips = await self.config.tips() + """Ensure default tips are present and remove old ones.""" + current_tips = await self.config.tips() changed = False + # Add missing defaults for tip in self.tips: - if tip not in tips: - tips.append(tip) + if tip not in current_tips: + current_tips.append(tip) + changed = True + + # Remove tips that are no longer in DEFAULT_TIPS + for tip in list(current_tips): + if tip not in self.tips: + current_tips.remove(tip) changed = True if changed: - await self.config.tips.set(tips) + await self.config.tips.set(current_tips) - self.tips = tips + self.tips = current_tips async def _get_effective_cooldown(self, user: discord.User, guild: Optional[discord.Guild]) -> int: """Resolve cooldown with priority: user -> guild -> global.""" From 442e2290599dc3576c27b8b58ed01ec85d5750a9 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:46:57 +0000 Subject: [PATCH 26/67] Add globe feed URL utilities and enhance map link extraction - Introduced `_globe_feed_url` and `_normalize_globe_feed_link` methods to handle globe.airplanes.live URLs for both UUID and feed parameters. - Updated `get_feed_map_link` to extract and normalize map links from JSON data. - Modified existing code to utilize the new methods for map link extraction and button creation, ensuring compatibility with both UUID and feed parameters. --- skysearch/utils/helpers.py | 64 ++++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/skysearch/utils/helpers.py b/skysearch/utils/helpers.py index 0e01a6a..89bc79b 100644 --- a/skysearch/utils/helpers.py +++ b/skysearch/utils/helpers.py @@ -5,7 +5,7 @@ import json import aiohttp import discord -from urllib.parse import quote_plus +from urllib.parse import quote_plus, urlparse, parse_qs class HelperUtils: @@ -414,6 +414,53 @@ async def get_navaid_data(self, airport_code: str): # for feeder link command stuff + def _globe_feed_url(self, ids: list, param: str = "uuid") -> str: + """ + Build a globe.airplanes.live URL for one or more feed UUIDs. + Supports both ?uuid= and ?feed= so either param works for feed info. + """ + if not ids: + return "https://globe.airplanes.live/" + # IDs are 16-char hex (no hyphens) + value = ",".join(ids) if len(ids) > 1 else ids[0] + return f"https://globe.airplanes.live/?{param}={value}" + + def _normalize_globe_feed_link(self, url: str) -> str: + """ + Normalize a globe.airplanes.live URL so both ?uuid and ?feed work. + If the URL has ?feed= or ?uuid=, keep it as-is (both work on the site). + """ + if not url or "globe.airplanes.live" not in url: + return url + parsed = urlparse(url) + qs = parse_qs(parsed.query) + # Prefer uuid for multi-feed, otherwise keep existing param + feed_val = qs.get("feed", qs.get("uuid")) + if feed_val: + # Keep first param we found so link format is preserved + return url + return url + + def get_feed_map_link(self, json_data: dict) -> str | None: + """ + Get map link for feeder info. Uses map_link from JSON if present + (supports both ?uuid= and ?feed=). Otherwise builds from beast_clients. + """ + map_link = json_data.get("map_link") if json_data else None + if map_link: + return self._normalize_globe_feed_link(map_link) + beast_clients = json_data.get("beast_clients", []) if json_data else [] + ids = [] + for client in beast_clients: + uuid_val = client.get("uuid") or "" + if uuid_val: + feed_id = uuid_val.replace("-", "")[:16] + if feed_id and feed_id not in ids: + ids.append(feed_id) + if ids: + return self._globe_feed_url(ids, param="uuid") + return None + async def parse_json_input(self, json_input: str): """ Parse JSON input from either a URL or direct JSON text. @@ -464,8 +511,8 @@ def create_feeder_embed(self, json_data: dict): host = json_data.get('host', 'Unknown') embed.add_field(name="Host", value=host, inline=True) - # Extract map link if available - map_link = json_data.get('map_link') + # Extract map link if available (?uuid and ?feed both supported) + map_link = self.get_feed_map_link(json_data) if map_link: embed.add_field(name="Map Link", value=f"[View on Globe]({map_link})", inline=False) embed.url = map_link @@ -521,8 +568,8 @@ def create_feeder_view(self, json_input: str, json_data: dict = None): """ view = discord.ui.View() - # Add main map link button - map_link = json_data.get('map_link') if json_data else None + # Add main map link button (?uuid and ?feed both supported) + map_link = self.get_feed_map_link(json_data) if json_data else None if map_link: view.add_item(discord.ui.Button( label="View on Globe", @@ -531,16 +578,15 @@ def create_feeder_view(self, json_input: str, json_data: dict = None): style=discord.ButtonStyle.link )) - # Add individual Beast client feed buttons + # Add individual Beast client feed buttons (use ?feed= for single; ?uuid= also works) if json_data: beast_clients = json_data.get('beast_clients', []) for i, client in enumerate(beast_clients[:5]): # Limit to 5 buttons uuid = client.get('uuid', '') if uuid: - # Create individual feed URL - use first 16 characters of UUID without hyphens - # This matches the pattern from the map_link in the JSON + # Create individual feed URL - both ?feed= and ?uuid= work on globe feed_uuid = uuid.replace('-', '')[:16] - feed_url = f"https://globe.airplanes.live/?feed={feed_uuid}" + feed_url = self._globe_feed_url([feed_uuid], param="feed") # Create feed name using first part of UUID # Use first 8 characters of UUID for a clean, short identifier From b3314b9a800072005561cbff54a9f55cd468e520 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:02:46 +0000 Subject: [PATCH 27/67] tweaks --- skysearch/skysearch.py | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index e377817..f3347cf 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -1276,38 +1276,33 @@ async def on_message(self, message): if message.guild is None: return - + + content = message.content.strip() + # Fastest check first: does message look like ICAO? (sync, no I/O) + # Most messages fail this - skip all async work for non-matches + if not self._icao_pattern.match(content): + return + guild_id = message.guild.id - + # Fast cache check - avoid expensive config reads if auto_icao is disabled - if guild_id in self._auto_icao_enabled_guilds: - # Guild is known to have auto_icao enabled - proceed with processing - # Double-check config in case cache is stale (should be rare) - auto_icao = await self.config.guild(message.guild).auto_icao() - if not auto_icao: - # Update cache if it was stale - self._auto_icao_enabled_guilds.discard(guild_id) - return - elif guild_id in self._auto_icao_checked_guilds: + if guild_id in self._auto_icao_checked_guilds: # Guild is known to have auto_icao disabled - fast return return - else: + if guild_id not in self._auto_icao_enabled_guilds: # First time seeing this guild - do one-time config check auto_icao = await self.config.guild(message.guild).auto_icao() self._auto_icao_checked_guilds.add(guild_id) - if auto_icao: - self._auto_icao_enabled_guilds.add(guild_id) - else: + if not auto_icao: return + self._auto_icao_enabled_guilds.add(guild_id) + # else: guild in _auto_icao_enabled_guilds - trust cache (updated on config change) # Ensure locales for non-command listener (only if auto_icao is enabled) await set_contextual_locales_from_guild(self.bot, message.guild) - content = message.content - # Use pre-compiled pattern - if self._icao_pattern.match(content): - ctx = await self.bot.get_context(message) - await self.aircraft_commands.aircraft_by_icao(ctx, content) + ctx = await self.bot.get_context(message) + await self.aircraft_commands.aircraft_by_icao(ctx, content) @commands.is_owner() @aircraft_group.command(name="simulateemergency") From 0cbd61d497802a71e571e7504e67434ef4966d47 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:36:24 +0000 Subject: [PATCH 28/67] faa status stuff https://nasstatus.faa.gov/api/airport-status-information --- skysearch/README.md | 38 +++++++++++++++ skysearch/commands/airport.py | 91 ++++++++++++++++++++++++++++++++++- skysearch/docs.md | 17 +++++++ skysearch/skysearch.py | 7 ++- 4 files changed, 151 insertions(+), 2 deletions(-) diff --git a/skysearch/README.md b/skysearch/README.md index 4094349..4e3c97d 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -60,6 +60,7 @@ To use the SkySearch cog, follow these steps: - `[p]airport runway ` - Get runway information - `[p]airport navaid ` - Get navigational aids - `[p]airport forecast ` - Get weather forecast +- `[p]airport faastatus [code]` - Get FAA National Airspace Status (delays/closures). Optionally filter by airport code (e.g., SAN, LAS) ### Admin Commands - `[p]aircraft alertchannel [#channel]` - Set alert channel @@ -103,3 +104,40 @@ there is 4 total pages in it - `Apistats` - shows apistats for the cog itself - `Guild` - allows you to change cog settings in the dashboard (uses ids, to get them enable developer mode on discord) - `Lookup` - allows you to lookup data directly in the cog dashboard page + +## 🔧 Utilities + +SkySearch includes several utility modules for common operations: + +### XML Parser (`utils/xml_parser.py`) +Utility class for parsing XML data from APIs with safe error handling. + +**Features:** +- Parse XML strings safely with error handling +- Find elements using XPath expressions +- Extract text content from XML elements +- Fetch and parse XML from URLs in one call + +**Usage:** +```python +from ..utils.xml_parser import XMLParser + +parser = XMLParser() + +# Fetch and parse XML from a URL +async with aiohttp.ClientSession() as session: + root = await parser.fetch_and_parse_xml(session, "https://api.example.com/data.xml") + if root: + elements = parser.find_elements(root, ".//Airport") + for element in elements: + code = parser.get_text(element, "ARPT") +``` + +**Methods:** +- `parse_xml_string(xml_string)` - Parse XML string safely +- `find_elements(root, xpath)` - Find elements using XPath +- `get_text(element, tag, default="")` - Extract text from child elements +- `fetch_and_parse_xml(session, url, headers=None)` - Fetch and parse XML from URL + +Used by: +- FAA National Airspace Status command (`airport faastatus`) diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index f80266b..0183493 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -5,10 +5,13 @@ import discord import aiohttp import asyncio +import re +from datetime import datetime from redbot.core.utils.menus import menu, DEFAULT_CONTROLS from ..utils.api import APIManager from ..utils.helpers import HelperUtils +from ..utils.xml_parser import XMLParser from redbot.core import commands from redbot.core.i18n import Translator, cog_i18n @@ -24,6 +27,7 @@ def __init__(self, cog): self.cog = cog self.api = APIManager(cog) self.helpers = HelperUtils(cog) + self.xml_parser = XMLParser() async def airport_info(self, ctx, airport_code: str): """Get airport information by ICAO or IATA code.""" @@ -378,4 +382,89 @@ async def owmkey(self, ctx): async def clearowmkey(self, ctx): """Clear the OpenWeatherMap API key.""" await self.cog.config.openweathermap_api.set(None) - await ctx.send("OpenWeatherMap API key cleared.") \ No newline at end of file + await ctx.send("OpenWeatherMap API key cleared.") + + async def faa_status(self, ctx, airport_code: str = None): + """Get FAA National Airspace Status for airports with delays or closures. + + If airport_code is provided, filters to that specific airport (e.g., 'SAN' or 'LAS'). + If not provided, shows all airports with active delays/closures. + """ + def clean_date(ts): + try: + # Extract the 'end' portion of the FAA timestamp 2601120800-2603190800 + dt_str = ts.split("-")[-1][:10] + dt = datetime.strptime(dt_str, "%y%m%d%H%M") + return dt.strftime("%b %d, %H:%M") + except: + return "Unknown" + + try: + # Include optional custom User-Agent + headers = {} + user_agent = await self.cog.config.user_agent() + if user_agent: + headers["User-Agent"] = user_agent + + async with aiohttp.ClientSession() as session: + root = await self.xml_parser.fetch_and_parse_xml( + session, + "https://nasstatus.faa.gov/api/airport-status-information", + headers if headers else None + ) + + if root is None: + await ctx.send("❌ FAA API Unavailable.") + return + + airports = self.xml_parser.find_elements(root, ".//Airport") + + # Filter by airport_code if provided + if airport_code: + airports = [a for a in airports if self.xml_parser.get_text(a, "ARPT") == airport_code.upper()] + + if not airports: + embed = discord.Embed( + description="✅ No active delays or closures reported.", + color=discord.Color.green() + ) + await ctx.send(embed=embed) + return + + embed = discord.Embed( + title="✈️ FAA National Airspace Status", + color=0x2b2d31 + ) + embed.set_footer(text="Times in UTC • Data refreshes every 60s") + + for airport in airports[:8]: # Discord limit is 25 fields; 8 is safe for mobile + code = self.xml_parser.get_text(airport, "ARPT") + raw = self.xml_parser.get_text(airport, "Reason") + + # 1. Remove Header: "!SAN 01/048 SAN " + clean_msg = re.sub(r'^![A-Z0-9]{3,4}\s\d+/\d+\s[A-Z0-9]{3,4}\s', '', raw) + # 2. Remove trailing date block: " 2601120800-2603190800" + clean_msg = re.sub(r'\s\d{10}-\d{10}$', '', clean_msg) + + # Humanize Jargon + clean_msg = (clean_msg.replace("AD AP CLSD TO NON SKED TRANSIENT GA ACFT EXC", "Closed to non-scheduled/private flights except") + .replace("PPR", "Prior Permission Required") + .replace("EXC", "except") + .strip()) + + expiration = clean_date(raw.split(" ")[-1]) + + embed.add_field( + name=f"📍 {code}", + value=f"{clean_msg}\n**Ends:** `{expiration}`", + inline=False + ) + + await ctx.send(embed=embed) + except Exception as e: + embed = discord.Embed( + title="Error", + description=f"Failed to fetch FAA status: {str(e)}", + color=0xff4545 + ) + await ctx.send(embed=embed) \ No newline at end of file diff --git a/skysearch/docs.md b/skysearch/docs.md index 206935e..93b794c 100644 --- a/skysearch/docs.md +++ b/skysearch/docs.md @@ -80,6 +80,23 @@ Shows runway details like length, width, and surface type. ``` Shows current weather and 3-day forecast. +### FAA National Airspace Status +``` +*airport faastatus +``` +Shows all airports with active delays or closures reported by the FAA. + +``` +*airport faastatus SAN +``` +Filters to show only San Diego International Airport (or any specific airport code). + +**What you'll see:** +- Airport codes with active delays/closures +- Human-readable descriptions of the issues +- Expiration times for each status update +- Data refreshes every 60 seconds from the FAA + ## Advanced Aircraft Search ### Find Aircraft Near You diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index f3347cf..e64ebf1 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -567,7 +567,7 @@ async def airport_group(self, ctx): """Command center for airport related commands""" embed = discord.Embed(title="Airport Commands", description="Available airport-related commands:", color=0xfffffe) embed.add_field(name="Information", value="`info` - Get airport information by ICAO/IATA code", inline=False) - embed.add_field(name="Details", value="`runway` - Get runway information\n`navaid` - Get navigational aids\n`forecast` - Get weather forecast", inline=False) + embed.add_field(name="Details", value="`runway` - Get runway information\n`navaid` - Get navigational aids\n`forecast` - Get weather forecast\n`faastatus [code]` - Get FAA National Airspace Status (delays/closures)", inline=False) embed.add_field(name="Detailed Help", value="Use `*help airport` for detailed command information", inline=False) await ctx.send(embed=embed) @@ -592,6 +592,11 @@ async def airport_forecast(self, ctx, code: str): """Get the weather for an airport by ICAO or IATA code (US airports only).""" await self.airport_commands.forecast(ctx, code) + @airport_group.command(name='faastatus', aliases=['faa'], help='Get FAA National Airspace Status for airports with delays or closures. Optionally filter by airport code.') + async def airport_faa_status(self, ctx, airport_code: str = None): + """Get FAA National Airspace Status for airports with delays or closures. Optionally filter by airport code (e.g., SAN, LAS).""" + await self.airport_commands.faa_status(ctx, airport_code) + @commands.is_owner() @airport_group.command(name="setowmkey") async def airport_setowmkey(self, ctx, api_key: str): From 8cabe0344eed1cc1d842fa9efdd208e13aa7fda3 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:38:17 +0000 Subject: [PATCH 29/67] forgot the xml parser file --- skysearch/utils/__init__.py | 1 + skysearch/utils/xml_parser.py | 124 ++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 skysearch/utils/xml_parser.py diff --git a/skysearch/utils/__init__.py b/skysearch/utils/__init__.py index effde3c..6a69b6f 100644 --- a/skysearch/utils/__init__.py +++ b/skysearch/utils/__init__.py @@ -6,5 +6,6 @@ - helpers.py: Provides helper functions for formatting, embeds, and data processing. - export.py: Manages exporting aircraft data to CSV, PDF, TXT, or HTML formats. - stats.py: handles api stats for airplanes.live requests. +- xml_parser.py: Utility class for parsing XML data from APIs with safe error handling. """ \ No newline at end of file diff --git a/skysearch/utils/xml_parser.py b/skysearch/utils/xml_parser.py new file mode 100644 index 0000000..b0ca777 --- /dev/null +++ b/skysearch/utils/xml_parser.py @@ -0,0 +1,124 @@ +""" +XML parsing utilities for SkySearch cog + +This module provides a utility class for parsing XML data from APIs, with +safe error handling and convenient methods for common XML operations. + +Example usage: + ```python + from ..utils.xml_parser import XMLParser + + parser = XMLParser() + + # Fetch and parse XML from a URL + async with aiohttp.ClientSession() as session: + root = await parser.fetch_and_parse_xml(session, "https://api.example.com/data.xml") + if root: + airports = parser.find_elements(root, ".//Airport") + for airport in airports: + code = parser.get_text(airport, "ARPT") + print(f"Airport code: {code}") + ``` +""" + +import xml.etree.ElementTree as ET +from typing import Optional, List, Dict, Any +import aiohttp + + +class XMLParser: + """ + Utility class for parsing XML data from APIs. + + Provides safe, convenient methods for common XML parsing operations + with built-in error handling. All methods return None or empty lists + on failure, making it easy to handle errors gracefully. + + All methods are static, so you can use them without instantiating the class: + ```python + root = XMLParser.parse_xml_string(xml_data) + ``` + + Or instantiate for convenience: + ```python + parser = XMLParser() + root = parser.parse_xml_string(xml_data) + ``` + """ + + @staticmethod + def parse_xml_string(xml_string: str) -> Optional[ET.Element]: + """ + Parse an XML string into an ElementTree Element. + + Args: + xml_string: The XML string to parse + + Returns: + ElementTree Element root, or None if parsing fails + """ + try: + return ET.fromstring(xml_string) + except ET.ParseError: + return None + + @staticmethod + def find_elements(root: ET.Element, xpath: str) -> List[ET.Element]: + """ + Find elements matching an XPath expression. + + Args: + root: The root ElementTree Element + xpath: XPath expression (e.g., ".//Airport") + + Returns: + List of matching elements + """ + try: + return root.findall(xpath) + except Exception: + return [] + + @staticmethod + def get_text(element: ET.Element, tag: str, default: str = "") -> str: + """ + Get text content from a child element. + + Args: + element: The parent ElementTree Element + tag: Tag name of the child element + default: Default value if element not found + + Returns: + Text content of the child element, or default + """ + child = element.find(tag) + if child is not None and child.text: + return child.text.strip() + return default + + @staticmethod + async def fetch_and_parse_xml( + session: aiohttp.ClientSession, + url: str, + headers: Optional[Dict[str, str]] = None + ) -> Optional[ET.Element]: + """ + Fetch XML from a URL and parse it. + + Args: + session: aiohttp ClientSession + url: URL to fetch XML from + headers: Optional HTTP headers + + Returns: + Parsed ElementTree Element root, or None if fetch/parse fails + """ + try: + async with session.get(url, headers=headers) as response: + if response.status != 200: + return None + xml_text = await response.text() + return XMLParser.parse_xml_string(xml_text) + except Exception: + return None From 105f6799ea134ad1f0ceb15c4674066a8b8936af Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:42:00 +0000 Subject: [PATCH 30/67] more --- skysearch/commands/airport.py | 167 ++++++++++++++++++++++++++-------- 1 file changed, 131 insertions(+), 36 deletions(-) diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index 0183493..69cfc1d 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -390,14 +390,18 @@ async def faa_status(self, ctx, airport_code: str = None): If airport_code is provided, filters to that specific airport (e.g., 'SAN' or 'LAS'). If not provided, shows all airports with active delays/closures. """ - def clean_date(ts): - try: - # Extract the 'end' portion of the FAA timestamp 2601120800-2603190800 - dt_str = ts.split("-")[-1][:10] - dt = datetime.strptime(dt_str, "%y%m%d%H%M") - return dt.strftime("%b %d, %H:%M") - except: - return "Unknown" + def clean_closure_reason(raw): + """Clean and humanize closure reason text.""" + # 1. Remove Header: "!SAN 01/048 SAN " + clean_msg = re.sub(r'^![A-Z0-9]{3,4}\s\d+/\d+\s[A-Z0-9]{3,4}\s', '', raw) + # 2. Remove trailing date block: " 2601120800-2603190800" + clean_msg = re.sub(r'\s\d{10}-\d{10}$', '', clean_msg) + # Humanize Jargon + clean_msg = (clean_msg.replace("AD AP CLSD TO NON SKED TRANSIENT GA ACFT EXC", "Closed to non-scheduled/private flights except") + .replace("PPR", "Prior Permission Required") + .replace("EXC", "except") + .strip()) + return clean_msg try: # Include optional custom User-Agent @@ -417,13 +421,61 @@ def clean_date(ts): await ctx.send("❌ FAA API Unavailable.") return - airports = self.xml_parser.find_elements(root, ".//Airport") - - # Filter by airport_code if provided - if airport_code: - airports = [a for a in airports if self.xml_parser.get_text(a, "ARPT") == airport_code.upper()] - - if not airports: + # Get update time + update_time = self.xml_parser.get_text(root, "Update_Time", "Unknown") + + # Collect all delay/closure data + ground_delays = [] + arrival_departure_delays = [] + closures = [] + + # Parse Ground Delay Programs + ground_delay_list = root.find(".//Ground_Delay_List") + if ground_delay_list is not None: + for delay in ground_delay_list.findall(".//Ground_Delay"): + arpt = self.xml_parser.get_text(delay, "ARPT") + if not airport_code or arpt == airport_code.upper(): + ground_delays.append({ + 'arpt': arpt, + 'reason': self.xml_parser.get_text(delay, "Reason"), + 'avg': self.xml_parser.get_text(delay, "Avg"), + 'max': self.xml_parser.get_text(delay, "Max") + }) + + # Parse General Arrival/Departure Delays + arrival_departure_list = root.find(".//Arrival_Departure_Delay_List") + if arrival_departure_list is not None: + for delay in arrival_departure_list.findall(".//Delay"): + arpt = self.xml_parser.get_text(delay, "ARPT") + if not airport_code or arpt == airport_code.upper(): + arr_dep = delay.find("Arrival_Departure") + if arr_dep is not None: + delay_type = arr_dep.get("Type", "Unknown") + arrival_departure_delays.append({ + 'arpt': arpt, + 'reason': self.xml_parser.get_text(delay, "Reason"), + 'type': delay_type, + 'min': self.xml_parser.get_text(arr_dep, "Min"), + 'max': self.xml_parser.get_text(arr_dep, "Max"), + 'trend': self.xml_parser.get_text(arr_dep, "Trend") + }) + + # Parse Airport Closures + closure_list = root.find(".//Airport_Closure_List") + if closure_list is not None: + for airport in closure_list.findall(".//Airport"): + arpt = self.xml_parser.get_text(airport, "ARPT") + if not airport_code or arpt == airport_code.upper(): + closures.append({ + 'arpt': arpt, + 'reason': clean_closure_reason(self.xml_parser.get_text(airport, "Reason")), + 'start': self.xml_parser.get_text(airport, "Start"), + 'reopen': self.xml_parser.get_text(airport, "Reopen") + }) + + # Check if we have any data + total_items = len(ground_delays) + len(arrival_departure_delays) + len(closures) + if total_items == 0: embed = discord.Embed( description="✅ No active delays or closures reported.", color=discord.Color.green() @@ -431,32 +483,75 @@ def clean_date(ts): await ctx.send(embed=embed) return + # Create embed embed = discord.Embed( title="✈️ FAA National Airspace Status", color=0x2b2d31 ) - embed.set_footer(text="Times in UTC • Data refreshes every 60s") - - for airport in airports[:8]: # Discord limit is 25 fields; 8 is safe for mobile - code = self.xml_parser.get_text(airport, "ARPT") - raw = self.xml_parser.get_text(airport, "Reason") - - # 1. Remove Header: "!SAN 01/048 SAN " - clean_msg = re.sub(r'^![A-Z0-9]{3,4}\s\d+/\d+\s[A-Z0-9]{3,4}\s', '', raw) - # 2. Remove trailing date block: " 2601120800-2603190800" - clean_msg = re.sub(r'\s\d{10}-\d{10}$', '', clean_msg) - - # Humanize Jargon - clean_msg = (clean_msg.replace("AD AP CLSD TO NON SKED TRANSIENT GA ACFT EXC", "Closed to non-scheduled/private flights except") - .replace("PPR", "Prior Permission Required") - .replace("EXC", "except") - .strip()) - - expiration = clean_date(raw.split(" ")[-1]) - + embed.set_footer(text=f"Updated: {update_time} • Times in UTC • Data refreshes every 60s") + + field_count = 0 + max_fields = 25 # Discord limit + + # Add Ground Delay Programs + if ground_delays: + for delay in ground_delays[:8]: # Limit per section + if field_count >= max_fields: + break + value = f"**Reason:** {delay['reason']}\n" + value += f"**Avg Delay:** {delay['avg']}\n" + value += f"**Max Delay:** {delay['max']}" + embed.add_field( + name=f"🛫 Ground Delay - {delay['arpt']}", + value=value, + inline=False + ) + field_count += 1 + + # Add Arrival/Departure Delays + if arrival_departure_delays: + for delay in arrival_departure_delays[:8]: # Limit per section + if field_count >= max_fields: + break + emoji = "🛬" if delay['type'].lower() == "arrival" else "🛫" + value = f"**Type:** {delay['type']}\n" + value += f"**Reason:** {delay['reason']}\n" + if delay['min']: + value += f"**Min Delay:** {delay['min']}\n" + if delay['max']: + value += f"**Max Delay:** {delay['max']}\n" + if delay['trend']: + trend_emoji = "📈" if delay['trend'].lower() == "increasing" else "📉" if delay['trend'].lower() == "decreasing" else "➡️" + value += f"**Trend:** {trend_emoji} {delay['trend']}" + embed.add_field( + name=f"{emoji} {delay['type']} Delay - {delay['arpt']}", + value=value, + inline=False + ) + field_count += 1 + + # Add Airport Closures + if closures: + for closure in closures[:8]: # Limit per section + if field_count >= max_fields: + break + value = f"{closure['reason']}\n" + if closure['start']: + value += f"**Started:** {closure['start']}\n" + if closure['reopen']: + value += f"**Reopens:** {closure['reopen']}" + embed.add_field( + name=f"🚫 Closure - {closure['arpt']}", + value=value, + inline=False + ) + field_count += 1 + + # Add note if we hit the limit + if field_count >= max_fields: embed.add_field( - name=f"📍 {code}", - value=f"{clean_msg}\n**Ends:** `{expiration}`", + name="⚠️ Note", + value="Display limited to 25 fields. Use a specific airport code to see all details.", inline=False ) From 06a6d9434396050c4f2a07af9c5d7f60abd86cb3 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:43:30 +0000 Subject: [PATCH 31/67] make it nicer to read --- skysearch/commands/airport.py | 69 ++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index 69cfc1d..7a88eab 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -498,11 +498,11 @@ def clean_closure_reason(raw): for delay in ground_delays[:8]: # Limit per section if field_count >= max_fields: break - value = f"**Reason:** {delay['reason']}\n" - value += f"**Avg Delay:** {delay['avg']}\n" - value += f"**Max Delay:** {delay['max']}" + # Format delays more concisely + delay_info = f"`{delay['avg']}` avg • `{delay['max']}` max" + value = f"{delay_info}\n**Reason:** {delay['reason']}" embed.add_field( - name=f"🛫 Ground Delay - {delay['arpt']}", + name=f"🛫 `{delay['arpt']}` Ground Delay", value=value, inline=False ) @@ -514,17 +514,30 @@ def clean_closure_reason(raw): if field_count >= max_fields: break emoji = "🛬" if delay['type'].lower() == "arrival" else "🛫" - value = f"**Type:** {delay['type']}\n" - value += f"**Reason:** {delay['reason']}\n" - if delay['min']: - value += f"**Min Delay:** {delay['min']}\n" - if delay['max']: - value += f"**Max Delay:** {delay['max']}\n" + type_name = delay['type'] + + # Build delay range string + delay_parts = [] + if delay['min'] and delay['max']: + delay_parts.append(f"`{delay['min']}` - `{delay['max']}`") + elif delay['min']: + delay_parts.append(f"`{delay['min']}` min") + elif delay['max']: + delay_parts.append(f"`{delay['max']}` max") + + value = "" + if delay_parts: + value = f"{' • '.join(delay_parts)}\n" + + value += f"**Reason:** {delay['reason']}" + + # Add trend with just emoji (no redundant text) if delay['trend']: trend_emoji = "📈" if delay['trend'].lower() == "increasing" else "📉" if delay['trend'].lower() == "decreasing" else "➡️" - value += f"**Trend:** {trend_emoji} {delay['trend']}" + value += f" {trend_emoji}" + embed.add_field( - name=f"{emoji} {delay['type']} Delay - {delay['arpt']}", + name=f"{emoji} `{delay['arpt']}` {type_name} Delay", value=value, inline=False ) @@ -535,13 +548,33 @@ def clean_closure_reason(raw): for closure in closures[:8]: # Limit per section if field_count >= max_fields: break - value = f"{closure['reason']}\n" + # Format closure reason better - extract phone numbers if present + reason = closure['reason'] + phone_match = re.search(r'(\d{3}-\d{3}-\d{4})', reason) + phone = phone_match.group(1) if phone_match else None + + # Clean up reason text + if phone: + reason = reason.replace(phone, "").strip() + # Remove extra spaces + reason = re.sub(r'\s+', ' ', reason) + + value = f"**{reason}**" + if phone: + value += f"\n📞 Contact: `{phone}`" + + # Add timing info + timing_parts = [] if closure['start']: - value += f"**Started:** {closure['start']}\n" + timing_parts.append(f"Started: `{closure['start']}`") if closure['reopen']: - value += f"**Reopens:** {closure['reopen']}" + timing_parts.append(f"Reopens: `{closure['reopen']}`") + + if timing_parts: + value += f"\n\n{' • '.join(timing_parts)}" + embed.add_field( - name=f"🚫 Closure - {closure['arpt']}", + name=f"🚫 `{closure['arpt']}` Closure", value=value, inline=False ) @@ -550,8 +583,8 @@ def clean_closure_reason(raw): # Add note if we hit the limit if field_count >= max_fields: embed.add_field( - name="⚠️ Note", - value="Display limited to 25 fields. Use a specific airport code to see all details.", + name="⚠️ Display Limit", + value="Showing first 25 items. Use `*airport faastatus ` to filter by airport.", inline=False ) From d1d26144f164d2f2b50b5907a190ae5d0a3180ea Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:45:26 +0000 Subject: [PATCH 32/67] dropdown selector --- skysearch/commands/airport.py | 308 ++++++++++++++++++++++------------ 1 file changed, 205 insertions(+), 103 deletions(-) diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index 7a88eab..5d07cfb 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -19,6 +19,191 @@ _ = Translator("Skysearch", __file__) +class FAAStatusView(discord.ui.View): + """View with dropdown to filter FAA status by delay type.""" + + def __init__(self, ground_delays, arrival_departure_delays, closures, update_time, airport_code=None): + super().__init__(timeout=300) # 5 minute timeout + self.ground_delays = ground_delays + self.arrival_departure_delays = arrival_departure_delays + self.closures = closures + self.update_time = update_time + self.airport_code = airport_code + self.current_filter = "all" + + # Build dropdown options + options = [ + discord.SelectOption( + label="All Issues", + value="all", + description="Show all delays and closures", + emoji="✈️", + default=True + ) + ] + + if ground_delays: + options.append(discord.SelectOption( + label="Ground Delays", + value="ground", + description=f"{len(ground_delays)} ground delay program(s)", + emoji="🛫" + )) + + if arrival_departure_delays: + options.append(discord.SelectOption( + label="Arrival/Departure Delays", + value="arrdep", + description=f"{len(arrival_departure_delays)} delay(s)", + emoji="🛬" + )) + + if closures: + options.append(discord.SelectOption( + label="Closures", + value="closures", + description=f"{len(closures)} closure(s)", + emoji="🚫" + )) + + if len(options) > 1: + self.select_menu = discord.ui.Select( + placeholder="Filter by delay type...", + options=options, + min_values=1, + max_values=1 + ) + self.select_menu.callback = self.on_select + self.add_item(self.select_menu) + + async def on_select(self, interaction: discord.Interaction): + """Handle dropdown selection.""" + selected = interaction.data['values'][0] + self.current_filter = selected + + # Update default option + for option in self.select_menu.options: + option.default = (option.value == selected) + + # Build new embed + embed = self.build_embed(selected) + await interaction.response.edit_message(embed=embed, view=self) + + def build_embed(self, filter_type="all"): + """Build embed based on filter type.""" + embed = discord.Embed( + title="✈️ FAA National Airspace Status", + color=0x2b2d31 + ) + + # Add filter indicator to footer + filter_text = { + "all": "All Issues", + "ground": "Ground Delays Only", + "arrdep": "Arrival/Departure Delays Only", + "closures": "Closures Only" + }.get(filter_type, "All Issues") + + embed.set_footer(text=f"{filter_text} • Updated: {self.update_time} • Times in UTC • Data refreshes every 60s") + + field_count = 0 + max_fields = 25 + + # Add Ground Delay Programs + if filter_type in ("all", "ground") and self.ground_delays: + for delay in self.ground_delays[:8]: + if field_count >= max_fields: + break + delay_info = f"`{delay['avg']}` avg • `{delay['max']}` max" + value = f"{delay_info}\n**Reason:** {delay['reason']}" + embed.add_field( + name=f"🛫 `{delay['arpt']}` Ground Delay", + value=value, + inline=False + ) + field_count += 1 + + # Add Arrival/Departure Delays + if filter_type in ("all", "arrdep") and self.arrival_departure_delays: + for delay in self.arrival_departure_delays[:8]: + if field_count >= max_fields: + break + emoji = "🛬" if delay['type'].lower() == "arrival" else "🛫" + type_name = delay['type'] + + delay_parts = [] + if delay['min'] and delay['max']: + delay_parts.append(f"`{delay['min']}` - `{delay['max']}`") + elif delay['min']: + delay_parts.append(f"`{delay['min']}` min") + elif delay['max']: + delay_parts.append(f"`{delay['max']}` max") + + value = "" + if delay_parts: + value = f"{' • '.join(delay_parts)}\n" + + value += f"**Reason:** {delay['reason']}" + + if delay['trend']: + trend_emoji = "📈" if delay['trend'].lower() == "increasing" else "📉" if delay['trend'].lower() == "decreasing" else "➡️" + value += f" {trend_emoji}" + + embed.add_field( + name=f"{emoji} `{delay['arpt']}` {type_name} Delay", + value=value, + inline=False + ) + field_count += 1 + + # Add Airport Closures + if filter_type in ("all", "closures") and self.closures: + for closure in self.closures[:8]: + if field_count >= max_fields: + break + reason = closure['reason'] + phone_match = re.search(r'(\d{3}-\d{3}-\d{4})', reason) + phone = phone_match.group(1) if phone_match else None + + if phone: + reason = reason.replace(phone, "").strip() + reason = re.sub(r'\s+', ' ', reason) + + value = f"**{reason}**" + if phone: + value += f"\n📞 Contact: `{phone}`" + + timing_parts = [] + if closure['start']: + timing_parts.append(f"Started: `{closure['start']}`") + if closure['reopen']: + timing_parts.append(f"Reopens: `{closure['reopen']}`") + + if timing_parts: + value += f"\n\n{' • '.join(timing_parts)}" + + embed.add_field( + name=f"🚫 `{closure['arpt']}` Closure", + value=value, + inline=False + ) + field_count += 1 + + # Add note if no results for filter + if field_count == 0: + embed.description = f"✅ No {filter_text.lower()} found." + + # Add note if we hit the limit + if field_count >= max_fields: + embed.add_field( + name="⚠️ Display Limit", + value="Showing first 25 items. Use `*airport faastatus ` to filter by airport.", + inline=False + ) + + return embed + + @cog_i18n(_) class AirportCommands: """Airport-related commands for SkySearch.""" @@ -483,112 +668,29 @@ def clean_closure_reason(raw): await ctx.send(embed=embed) return - # Create embed - embed = discord.Embed( - title="✈️ FAA National Airspace Status", - color=0x2b2d31 + # Create view with dropdown selector + view = FAAStatusView( + ground_delays=ground_delays, + arrival_departure_delays=arrival_departure_delays, + closures=closures, + update_time=update_time, + airport_code=airport_code ) - embed.set_footer(text=f"Updated: {update_time} • Times in UTC • Data refreshes every 60s") - - field_count = 0 - max_fields = 25 # Discord limit - - # Add Ground Delay Programs - if ground_delays: - for delay in ground_delays[:8]: # Limit per section - if field_count >= max_fields: - break - # Format delays more concisely - delay_info = f"`{delay['avg']}` avg • `{delay['max']}` max" - value = f"{delay_info}\n**Reason:** {delay['reason']}" - embed.add_field( - name=f"🛫 `{delay['arpt']}` Ground Delay", - value=value, - inline=False - ) - field_count += 1 - - # Add Arrival/Departure Delays - if arrival_departure_delays: - for delay in arrival_departure_delays[:8]: # Limit per section - if field_count >= max_fields: - break - emoji = "🛬" if delay['type'].lower() == "arrival" else "🛫" - type_name = delay['type'] - - # Build delay range string - delay_parts = [] - if delay['min'] and delay['max']: - delay_parts.append(f"`{delay['min']}` - `{delay['max']}`") - elif delay['min']: - delay_parts.append(f"`{delay['min']}` min") - elif delay['max']: - delay_parts.append(f"`{delay['max']}` max") - - value = "" - if delay_parts: - value = f"{' • '.join(delay_parts)}\n" - - value += f"**Reason:** {delay['reason']}" - - # Add trend with just emoji (no redundant text) - if delay['trend']: - trend_emoji = "📈" if delay['trend'].lower() == "increasing" else "📉" if delay['trend'].lower() == "decreasing" else "➡️" - value += f" {trend_emoji}" - - embed.add_field( - name=f"{emoji} `{delay['arpt']}` {type_name} Delay", - value=value, - inline=False - ) - field_count += 1 - # Add Airport Closures - if closures: - for closure in closures[:8]: # Limit per section - if field_count >= max_fields: - break - # Format closure reason better - extract phone numbers if present - reason = closure['reason'] - phone_match = re.search(r'(\d{3}-\d{3}-\d{4})', reason) - phone = phone_match.group(1) if phone_match else None - - # Clean up reason text - if phone: - reason = reason.replace(phone, "").strip() - # Remove extra spaces - reason = re.sub(r'\s+', ' ', reason) - - value = f"**{reason}**" - if phone: - value += f"\n📞 Contact: `{phone}`" - - # Add timing info - timing_parts = [] - if closure['start']: - timing_parts.append(f"Started: `{closure['start']}`") - if closure['reopen']: - timing_parts.append(f"Reopens: `{closure['reopen']}`") - - if timing_parts: - value += f"\n\n{' • '.join(timing_parts)}" - - embed.add_field( - name=f"🚫 `{closure['arpt']}` Closure", - value=value, - inline=False - ) - field_count += 1 + # Build initial embed (showing all) + embed = view.build_embed("all") - # Add note if we hit the limit - if field_count >= max_fields: - embed.add_field( - name="⚠️ Display Limit", - value="Showing first 25 items. Use `*airport faastatus ` to filter by airport.", - inline=False - ) - - await ctx.send(embed=embed) + # Only add view if there are multiple types to filter + if len(ground_delays) > 0 and len(arrival_departure_delays) > 0 and len(closures) > 0: + await ctx.send(embed=embed, view=view) + elif (len(ground_delays) > 0 and len(arrival_departure_delays) > 0) or \ + (len(ground_delays) > 0 and len(closures) > 0) or \ + (len(arrival_departure_delays) > 0 and len(closures) > 0): + # Multiple types but not all three - still show dropdown + await ctx.send(embed=embed, view=view) + else: + # Only one type - no need for dropdown + await ctx.send(embed=embed) except Exception as e: embed = discord.Embed( title="Error", From 30a214453facdfc3bb493d3f1e7527bf698ae458 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:44:13 +0000 Subject: [PATCH 33/67] bvbv --- skysearch/README.md | 3 +- skysearch/commands/admin.py | 111 +++++++++++++++++ skysearch/commands/airport.py | 216 ++++++++++++++++++---------------- skysearch/skysearch.py | 105 ++++++++++++++++- 4 files changed, 329 insertions(+), 106 deletions(-) diff --git a/skysearch/README.md b/skysearch/README.md index 4e3c97d..2e781ea 100644 --- a/skysearch/README.md +++ b/skysearch/README.md @@ -60,7 +60,8 @@ To use the SkySearch cog, follow these steps: - `[p]airport runway ` - Get runway information - `[p]airport navaid ` - Get navigational aids - `[p]airport forecast ` - Get weather forecast -- `[p]airport faastatus [code]` - Get FAA National Airspace Status (delays/closures). Optionally filter by airport code (e.g., SAN, LAS) +- `[p]airport faastatus [code]` - Get FAA National Airspace Status (delays/closures). Optionally filter by airport code (e.g., SAN, LAS). Use the dropdown to filter by type; use **Refresh** to re-fetch. +- **FAA status alerts** (like squawk alerts): `[p]airport faaalertchannel [#channel]` `[p]airport faaalertrole [@role]` `[p]airport faaalertcooldown [minutes]` `[p]airport showfaaalerts` — get notified when FAA delays/closures change (task runs every 5 minutes). ### Admin Commands - `[p]aircraft alertchannel [#channel]` - Set alert channel diff --git a/skysearch/commands/admin.py b/skysearch/commands/admin.py index a28831c..fb666ff 100644 --- a/skysearch/commands/admin.py +++ b/skysearch/commands/admin.py @@ -98,6 +98,117 @@ async def set_alert_cooldown(self, ctx, duration: str = None): else: await ctx.send(_("Current emergency alert cooldown is {minutes} minutes.").format(minutes=int(cooldown))) + async def set_faa_alert_channel(self, ctx, channel: discord.TextChannel = None): + """Set or clear the channel for FAA status change notifications. Clear with no channel.""" + if channel: + try: + await self.cog.config.guild(ctx.guild).faa_alert_channel.set(channel.id) + embed = discord.Embed( + description=_("FAA status alert channel set to {channel}").format(channel=channel.mention), + color=0xfffffe + ) + await ctx.send(embed=embed) + except Exception as e: + embed = discord.Embed(description=f"Error setting FAA alert channel: {e}", color=0xff4545) + await ctx.send(embed=embed) + else: + try: + await self.cog.config.guild(ctx.guild).faa_alert_channel.clear() + embed = discord.Embed( + description=_("FAA status alert channel cleared. No more FAA change notifications will be sent."), + color=0xfffffe + ) + await ctx.send(embed=embed) + except Exception as e: + embed = discord.Embed(description=f"Error clearing FAA alert channel: {e}", color=0xff4545) + await ctx.send(embed=embed) + + async def set_faa_alert_role(self, ctx, role: discord.Role = None): + """Set or clear the role to mention when FAA status changes. Clear with no role.""" + if role: + try: + await self.cog.config.guild(ctx.guild).faa_alert_role.set(role.id) + embed = discord.Embed( + description=_("FAA status alert role set to {role}").format(role=role.mention), + color=0xfffffe + ) + await ctx.send(embed=embed) + except Exception as e: + embed = discord.Embed(description=f"Error setting FAA alert role: {e}", color=0xff4545) + await ctx.send(embed=embed) + else: + try: + await self.cog.config.guild(ctx.guild).faa_alert_role.clear() + embed = discord.Embed( + description=_("FAA status alert role cleared."), + color=0xfffffe + ) + await ctx.send(embed=embed) + except Exception as e: + embed = discord.Embed(description=f"Error clearing FAA alert role: {e}", color=0xff4545) + await ctx.send(embed=embed) + + async def set_faa_alert_cooldown(self, ctx, duration: str = None): + """Set or show cooldown for FAA status change notifications (minutes). Default 5.""" + if duration is not None: + try: + if duration.endswith("s"): + seconds = int(duration[:-1]) + minutes = seconds / 60 + elif duration.endswith("m"): + minutes = int(duration[:-1]) + else: + minutes = int(duration) + if minutes < 0: + await ctx.send(_("Cooldown must be a positive number.")) + return + await self.cog.config.guild(ctx.guild).faa_alert_cooldown.set(minutes) + if minutes < 1: + await ctx.send(_("FAA alert cooldown set to {seconds} seconds.").format(seconds=int(minutes * 60))) + else: + await ctx.send(_("FAA alert cooldown set to {minutes} minutes.").format(minutes=int(minutes))) + except ValueError: + await ctx.send(_("Invalid duration. Use a number, '5m', or '30s'.")) + else: + cooldown = await self.cog.config.guild(ctx.guild).faa_alert_cooldown() + if cooldown < 1: + await ctx.send(_("Current FAA alert cooldown is {seconds} seconds.").format(seconds=int(cooldown * 60))) + else: + await ctx.send(_("Current FAA alert cooldown is {minutes} minutes.").format(minutes=int(cooldown))) + + async def list_faa_alert_channels(self, ctx): + """Show FAA status alert channel and role for this server.""" + guild = ctx.guild + embed = discord.Embed(title=f"FAA status alerts for {guild.name}", color=0xfffffe) + channel_id = await self.cog.config.guild(guild).faa_alert_channel() + role_id = await self.cog.config.guild(guild).faa_alert_role() + cooldown = await self.cog.config.guild(guild).faa_alert_cooldown() + if channel_id: + ch = self.cog.bot.get_channel(channel_id) + embed.add_field( + name="Channel", + value=ch.mention if ch else f"Unknown ({channel_id})", + inline=True + ) + else: + embed.add_field(name="Channel", value="Not set", inline=True) + if role_id: + role = guild.get_role(role_id) + embed.add_field( + name="Role", + value=role.mention if role else f"Unknown ({role_id})", + inline=True + ) + else: + embed.add_field(name="Role", value="Not set", inline=True) + embed.add_field( + name="Cooldown", + value=f"{int(cooldown)} min" if cooldown >= 1 else f"{int(cooldown * 60)} sec", + inline=True + ) + embed.set_footer(text="Use airport faaalertchannel / faaalertrole / faaalertcooldown to change.") + await ctx.send(embed=embed) + async def autoicao(self, ctx, state: bool = None): """Enable or disable automatic ICAO lookup.""" if state is None: diff --git a/skysearch/commands/airport.py b/skysearch/commands/airport.py index 5d07cfb..6f1c2e0 100644 --- a/skysearch/commands/airport.py +++ b/skysearch/commands/airport.py @@ -19,16 +19,42 @@ _ = Translator("Skysearch", __file__) +class FAAStatusRefreshButton(discord.ui.Button): + """Button to refresh FAA status data.""" + + def __init__(self): + super().__init__(style=discord.ButtonStyle.secondary, label="Refresh", emoji="🔄", custom_id="faa_status_refresh") + + async def callback(self, interaction: discord.Interaction): + view = self.view + if not isinstance(view, FAAStatusView) or view.airport_commands is None: + await interaction.response.defer() + return + await interaction.response.defer() + result = await view.airport_commands._faa_fetch_data(view.airport_code) + if result is None: + await interaction.followup.send("Could not refresh; FAA API may be unavailable.", ephemeral=True) + return + ground_delays, arrival_departure_delays, closures, update_time = result + view.ground_delays = ground_delays + view.arrival_departure_delays = arrival_departure_delays + view.closures = closures + view.update_time = update_time + embed = view.build_embed(view.current_filter) + await interaction.edit_original_response(embed=embed, view=view) + + class FAAStatusView(discord.ui.View): - """View with dropdown to filter FAA status by delay type.""" + """View with dropdown to filter FAA status by delay type and optional Refresh button.""" - def __init__(self, ground_delays, arrival_departure_delays, closures, update_time, airport_code=None): - super().__init__(timeout=300) # 5 minute timeout + def __init__(self, ground_delays, arrival_departure_delays, closures, update_time, airport_code=None, airport_commands=None): + super().__init__(timeout=600) # 10 minute timeout self.ground_delays = ground_delays self.arrival_departure_delays = arrival_departure_delays self.closures = closures self.update_time = update_time self.airport_code = airport_code + self.airport_commands = airport_commands self.current_filter = "all" # Build dropdown options @@ -75,6 +101,10 @@ def __init__(self, ground_delays, arrival_departure_delays, closures, update_tim ) self.select_menu.callback = self.on_select self.add_item(self.select_menu) + + # Refresh button (when airport_commands is provided so we can re-fetch) + if airport_commands is not None: + self.add_item(FAAStatusRefreshButton()) async def on_select(self, interaction: discord.Interaction): """Handle dropdown selection.""" @@ -204,6 +234,17 @@ def build_embed(self, filter_type="all"): return embed +def _clean_closure_reason(raw): + """Clean and humanize closure reason text from FAA XML.""" + clean_msg = re.sub(r'^![A-Z0-9]{3,4}\s\d+/\d+\s[A-Z0-9]{3,4}\s', '', raw) + clean_msg = re.sub(r'\s\d{10}-\d{10}$', '', clean_msg) + clean_msg = (clean_msg.replace("AD AP CLSD TO NON SKED TRANSIENT GA ACFT EXC", "Closed to non-scheduled/private flights except") + .replace("PPR", "Prior Permission Required") + .replace("EXC", "except") + .strip()) + return clean_msg + + @cog_i18n(_) class AirportCommands: """Airport-related commands for SkySearch.""" @@ -214,6 +255,67 @@ def __init__(self, cog): self.helpers = HelperUtils(cog) self.xml_parser = XMLParser() + async def _faa_fetch_data(self, airport_code: str = None): + """Fetch and parse FAA airport status. Returns (ground_delays, arrival_departure_delays, closures, update_time) or None.""" + try: + headers = {} + user_agent = await self.cog.config.user_agent() + if user_agent: + headers["User-Agent"] = user_agent + async with aiohttp.ClientSession() as session: + root = await self.xml_parser.fetch_and_parse_xml( + session, + "https://nasstatus.faa.gov/api/airport-status-information", + headers if headers else None + ) + if root is None: + return None + update_time = self.xml_parser.get_text(root, "Update_Time", "Unknown") + ground_delays = [] + arrival_departure_delays = [] + closures = [] + ground_delay_list = root.find(".//Ground_Delay_List") + if ground_delay_list is not None: + for delay in ground_delay_list.findall(".//Ground_Delay"): + arpt = self.xml_parser.get_text(delay, "ARPT") + if not airport_code or arpt == airport_code.upper(): + ground_delays.append({ + 'arpt': arpt, + 'reason': self.xml_parser.get_text(delay, "Reason"), + 'avg': self.xml_parser.get_text(delay, "Avg"), + 'max': self.xml_parser.get_text(delay, "Max") + }) + arrival_departure_list = root.find(".//Arrival_Departure_Delay_List") + if arrival_departure_list is not None: + for delay in arrival_departure_list.findall(".//Delay"): + arpt = self.xml_parser.get_text(delay, "ARPT") + if not airport_code or arpt == airport_code.upper(): + arr_dep = delay.find("Arrival_Departure") + if arr_dep is not None: + delay_type = arr_dep.get("Type", "Unknown") + arrival_departure_delays.append({ + 'arpt': arpt, + 'reason': self.xml_parser.get_text(delay, "Reason"), + 'type': delay_type, + 'min': self.xml_parser.get_text(arr_dep, "Min"), + 'max': self.xml_parser.get_text(arr_dep, "Max"), + 'trend': self.xml_parser.get_text(arr_dep, "Trend") + }) + closure_list = root.find(".//Airport_Closure_List") + if closure_list is not None: + for airport in closure_list.findall(".//Airport"): + arpt = self.xml_parser.get_text(airport, "ARPT") + if not airport_code or arpt == airport_code.upper(): + closures.append({ + 'arpt': arpt, + 'reason': _clean_closure_reason(self.xml_parser.get_text(airport, "Reason")), + 'start': self.xml_parser.get_text(airport, "Start"), + 'reopen': self.xml_parser.get_text(airport, "Reopen") + }) + return (ground_delays, arrival_departure_delays, closures, update_time) + except Exception: + return None + async def airport_info(self, ctx, airport_code: str): """Get airport information by ICAO or IATA code.""" airport_code = airport_code.upper() @@ -575,90 +677,12 @@ async def faa_status(self, ctx, airport_code: str = None): If airport_code is provided, filters to that specific airport (e.g., 'SAN' or 'LAS'). If not provided, shows all airports with active delays/closures. """ - def clean_closure_reason(raw): - """Clean and humanize closure reason text.""" - # 1. Remove Header: "!SAN 01/048 SAN " - clean_msg = re.sub(r'^![A-Z0-9]{3,4}\s\d+/\d+\s[A-Z0-9]{3,4}\s', '', raw) - # 2. Remove trailing date block: " 2601120800-2603190800" - clean_msg = re.sub(r'\s\d{10}-\d{10}$', '', clean_msg) - # Humanize Jargon - clean_msg = (clean_msg.replace("AD AP CLSD TO NON SKED TRANSIENT GA ACFT EXC", "Closed to non-scheduled/private flights except") - .replace("PPR", "Prior Permission Required") - .replace("EXC", "except") - .strip()) - return clean_msg - try: - # Include optional custom User-Agent - headers = {} - user_agent = await self.cog.config.user_agent() - if user_agent: - headers["User-Agent"] = user_agent - - async with aiohttp.ClientSession() as session: - root = await self.xml_parser.fetch_and_parse_xml( - session, - "https://nasstatus.faa.gov/api/airport-status-information", - headers if headers else None - ) - - if root is None: - await ctx.send("❌ FAA API Unavailable.") - return - - # Get update time - update_time = self.xml_parser.get_text(root, "Update_Time", "Unknown") - - # Collect all delay/closure data - ground_delays = [] - arrival_departure_delays = [] - closures = [] - - # Parse Ground Delay Programs - ground_delay_list = root.find(".//Ground_Delay_List") - if ground_delay_list is not None: - for delay in ground_delay_list.findall(".//Ground_Delay"): - arpt = self.xml_parser.get_text(delay, "ARPT") - if not airport_code or arpt == airport_code.upper(): - ground_delays.append({ - 'arpt': arpt, - 'reason': self.xml_parser.get_text(delay, "Reason"), - 'avg': self.xml_parser.get_text(delay, "Avg"), - 'max': self.xml_parser.get_text(delay, "Max") - }) - - # Parse General Arrival/Departure Delays - arrival_departure_list = root.find(".//Arrival_Departure_Delay_List") - if arrival_departure_list is not None: - for delay in arrival_departure_list.findall(".//Delay"): - arpt = self.xml_parser.get_text(delay, "ARPT") - if not airport_code or arpt == airport_code.upper(): - arr_dep = delay.find("Arrival_Departure") - if arr_dep is not None: - delay_type = arr_dep.get("Type", "Unknown") - arrival_departure_delays.append({ - 'arpt': arpt, - 'reason': self.xml_parser.get_text(delay, "Reason"), - 'type': delay_type, - 'min': self.xml_parser.get_text(arr_dep, "Min"), - 'max': self.xml_parser.get_text(arr_dep, "Max"), - 'trend': self.xml_parser.get_text(arr_dep, "Trend") - }) - - # Parse Airport Closures - closure_list = root.find(".//Airport_Closure_List") - if closure_list is not None: - for airport in closure_list.findall(".//Airport"): - arpt = self.xml_parser.get_text(airport, "ARPT") - if not airport_code or arpt == airport_code.upper(): - closures.append({ - 'arpt': arpt, - 'reason': clean_closure_reason(self.xml_parser.get_text(airport, "Reason")), - 'start': self.xml_parser.get_text(airport, "Start"), - 'reopen': self.xml_parser.get_text(airport, "Reopen") - }) - - # Check if we have any data + result = await self._faa_fetch_data(airport_code) + if result is None: + await ctx.send("❌ FAA API Unavailable.") + return + ground_delays, arrival_departure_delays, closures, update_time = result total_items = len(ground_delays) + len(arrival_departure_delays) + len(closures) if total_items == 0: embed = discord.Embed( @@ -667,30 +691,16 @@ def clean_closure_reason(raw): ) await ctx.send(embed=embed) return - - # Create view with dropdown selector view = FAAStatusView( ground_delays=ground_delays, arrival_departure_delays=arrival_departure_delays, closures=closures, update_time=update_time, - airport_code=airport_code + airport_code=airport_code, + airport_commands=self ) - - # Build initial embed (showing all) embed = view.build_embed("all") - - # Only add view if there are multiple types to filter - if len(ground_delays) > 0 and len(arrival_departure_delays) > 0 and len(closures) > 0: - await ctx.send(embed=embed, view=view) - elif (len(ground_delays) > 0 and len(arrival_departure_delays) > 0) or \ - (len(ground_delays) > 0 and len(closures) > 0) or \ - (len(arrival_departure_delays) > 0 and len(closures) > 0): - # Multiple types but not all three - still show dropdown - await ctx.send(embed=embed, view=view) - else: - # Only one type - no need for dropdown - await ctx.send(embed=embed) + await ctx.send(embed=embed, view=view) except Exception as e: embed = discord.Embed( title="Error", diff --git a/skysearch/skysearch.py b/skysearch/skysearch.py index e64ebf1..d775067 100644 --- a/skysearch/skysearch.py +++ b/skysearch/skysearch.py @@ -24,7 +24,7 @@ from .utils.helpers import HelperUtils from .utils.export import ExportManager from .commands.aircraft import AircraftCommands -from .commands.airport import AirportCommands +from .commands.airport import AirportCommands, FAAStatusView from .commands.admin import AdminCommands from .dashboard.dashboard_integration import DashboardIntegration from .api.squawk_api import SquawkAlertAPI @@ -50,7 +50,7 @@ def __init__(self, bot): self.config.register_global(api_mode="primary") # API mode: 'primary' or 'fallback (going to remove this when airplanes.live removes the public api because of companies abusing it...when that happens you'll need an api key for it)' self.config.register_global(user_agent=None) # Optional custom User-Agent header for all outbound HTTP requests self.config.register_global(api_stats=None) # API request statistics for persistence - self.config.register_guild(alert_channel=None, alert_role=None, auto_icao=False, auto_delete_not_found=True, emergency_cooldown=5, last_alerts={}, custom_alerts={}) + self.config.register_guild(alert_channel=None, alert_role=None, auto_icao=False, auto_delete_not_found=True, emergency_cooldown=5, last_alerts={}, custom_alerts={}, faa_alert_channel=None, faa_alert_role=None, faa_alert_cooldown=5, last_faa_status=None, faa_last_alert_time=None) self.config.register_user(watchlist=[], watchlist_notifications={}, watchlist_cooldown=10, watchlist_aircraft_state={}) # User watchlist: list of ICAO codes, dict of last notification times, cooldown in minutes (default: 10), and dict of last known aircraft state (flying/landed) # Initialize utility managers @@ -77,6 +77,7 @@ def __init__(self, bot): # Start background tasks self.check_emergency_squawks.start() self.check_watched_aircraft.start() + self.check_faa_status_changes.start() # Squawk alert API self.squawk_api = SquawkAlertAPI() @@ -155,6 +156,7 @@ async def cog_unload(self): """Clean up when the cog is unloaded.""" self.check_emergency_squawks.cancel() self.check_watched_aircraft.cancel() + self.check_faa_status_changes.cancel() await self.api.close() @commands.guild_only() @@ -568,6 +570,7 @@ async def airport_group(self, ctx): embed = discord.Embed(title="Airport Commands", description="Available airport-related commands:", color=0xfffffe) embed.add_field(name="Information", value="`info` - Get airport information by ICAO/IATA code", inline=False) embed.add_field(name="Details", value="`runway` - Get runway information\n`navaid` - Get navigational aids\n`forecast` - Get weather forecast\n`faastatus [code]` - Get FAA National Airspace Status (delays/closures)", inline=False) + embed.add_field(name="FAA Alerts", value="`faaalertchannel` `faaalertrole` `faaalertcooldown` `showfaaalerts` - Notify when FAA status changes", inline=False) embed.add_field(name="Detailed Help", value="Use `*help airport` for detailed command information", inline=False) await ctx.send(embed=embed) @@ -597,6 +600,26 @@ async def airport_faa_status(self, ctx, airport_code: str = None): """Get FAA National Airspace Status for airports with delays or closures. Optionally filter by airport code (e.g., SAN, LAS).""" await self.airport_commands.faa_status(ctx, airport_code) + @airport_group.command(name='faaalertchannel', help='Set or clear the channel for FAA status change notifications.') + async def airport_faa_alert_channel(self, ctx, channel: discord.TextChannel = None): + """Set or clear the channel for FAA status change notifications.""" + await self.admin_commands.set_faa_alert_channel(ctx, channel) + + @airport_group.command(name='faaalertrole', help='Set or clear the role to mention when FAA status changes.') + async def airport_faa_alert_role(self, ctx, role: discord.Role = None): + """Set or clear the role to mention when FAA status changes.""" + await self.admin_commands.set_faa_alert_role(ctx, role) + + @airport_group.command(name='faaalertcooldown', help='Set or show cooldown for FAA status change notifications (minutes).') + async def airport_faa_alert_cooldown(self, ctx, duration: str = None): + """Set or show cooldown for FAA status change notifications.""" + await self.admin_commands.set_faa_alert_cooldown(ctx, duration) + + @airport_group.command(name='showfaaalerts', help='Show current FAA status alert channel and role.') + async def airport_show_faa_alerts(self, ctx): + """Show current FAA status alert settings.""" + await self.admin_commands.list_faa_alert_channels(ctx) + @commands.is_owner() @airport_group.command(name="setowmkey") async def airport_setowmkey(self, ctx, api_key: str): @@ -837,6 +860,84 @@ async def check_emergency_squawks(self): async def before_check_emergency_squawks(self): """Wait for bot to be ready before starting the task.""" await self.bot.wait_until_ready() + + def _faa_snapshot_signature(self, ground_delays, arrival_departure_delays, closures, update_time): + """Build a comparable signature for FAA status (for change detection).""" + g = tuple(sorted((d["arpt"], d["reason"], d["avg"], d["max"]) for d in ground_delays)) + a = tuple(sorted((d["arpt"], d["reason"], d["type"], d["min"], d["max"], d["trend"]) for d in arrival_departure_delays)) + c = tuple(sorted((d["arpt"], d["reason"], d["start"], d["reopen"]) for d in closures)) + return (update_time, g, a, c) + + @tasks.loop(minutes=5) + async def check_faa_status_changes(self): + """Background task to check for FAA status changes and notify guilds with FAA alerts enabled.""" + try: + result = await self.airport_commands._faa_fetch_data(None) + if result is None: + return + ground_delays, arrival_departure_delays, closures, update_time = result + current_sig = self._faa_snapshot_signature(ground_delays, arrival_departure_delays, closures, update_time) + now = datetime.datetime.now(datetime.timezone.utc) + + for guild in self.bot.guilds: + try: + await set_contextual_locales_from_guild(self.bot, guild) + guild_config = self.config.guild(guild) + channel_id = await guild_config.faa_alert_channel() + if not channel_id: + continue + last = await guild_config.last_faa_status() + last_sig = None + if last and isinstance(last, dict): + last_sig = self._faa_snapshot_signature( + last.get("ground_delays") or [], + last.get("arrival_departure_delays") or [], + last.get("closures") or [], + last.get("update_time") or "" + ) + if last_sig is None: + await guild_config.last_faa_status.set({ + "update_time": update_time, + "ground_delays": ground_delays, + "arrival_departure_delays": arrival_departure_delays, + "closures": closures, + }) + continue + if current_sig == last_sig: + continue + cooldown_min = await guild_config.faa_alert_cooldown() + last_alert = await guild_config.faa_last_alert_time() + if last_alert is not None: + last_alert_dt = datetime.datetime.fromtimestamp(last_alert, tz=datetime.timezone.utc) + if (now - last_alert_dt).total_seconds() < cooldown_min * 60: + continue + channel = self.bot.get_channel(channel_id) + if not channel: + continue + role_id = await guild_config.faa_alert_role() + role_mention = f"<@&{role_id}>" if role_id else "" + view = FAAStatusView( + ground_delays, arrival_departure_delays, closures, update_time, + airport_code=None, airport_commands=None + ) + embed = view.build_embed("all") + embed.set_footer(text=f"FAA status changed • Updated: {update_time} • Times in UTC") + await channel.send(content=role_mention or None, embed=embed) + await guild_config.last_faa_status.set({ + "update_time": update_time, + "ground_delays": ground_delays, + "arrival_departure_delays": arrival_departure_delays, + "closures": closures, + }) + await guild_config.faa_last_alert_time.set(now.timestamp()) + except Exception as e: + log.debug(f"FAA status check guild {guild.id}: {e}") + except Exception as e: + log.error(f"Error checking FAA status changes: {e}", exc_info=True) + + @check_faa_status_changes.before_loop + async def before_check_faa_status_changes(self): + await self.bot.wait_until_ready() @tasks.loop(minutes=3) async def check_watched_aircraft(self): From 8855c4e7310402f50a1b688efc8c7e539c3dbbe7 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:34:30 +0000 Subject: [PATCH 34/67] fix info.json in clusters cog --- clusters/info.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/clusters/info.json b/clusters/info.json index 822d56e..2019405 100644 --- a/clusters/info.json +++ b/clusters/info.json @@ -1,7 +1,13 @@ { "name": "Clusters", - "author": "bencos17", + "author": [ + "bencos17" + ], "description": "Shows dynamic Marvel-themed cluster statuses for your bot.", "requirements": [], - "tags": ["clusters", "shards", "status"] -} + "tags": [ + "clusters", + "shards", + "status" + ] +} \ No newline at end of file From 7ee2190e37881492b3746e0716ac62c18ccbcce4 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:40:09 +0000 Subject: [PATCH 35/67] Update info.json --- clusters/info.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/clusters/info.json b/clusters/info.json index 2019405..77b80cc 100644 --- a/clusters/info.json +++ b/clusters/info.json @@ -3,10 +3,9 @@ "author": [ "bencos17" ], - "description": "Shows dynamic Marvel-themed cluster statuses for your bot.", + "description": "Shows dynamic Marvel-themed cluster statuses for your bot, curreently it doesn't support physical clusters though but I do want to add support to my red instance for it so some day it will", "requirements": [], "tags": [ - "clusters", "shards", "status" ] From 127154a2c07a053f15d4b4a0c873f50a7ee703d0 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:43:25 +0000 Subject: [PATCH 36/67] Update clusters.py --- clusters/clusters.py | 53 +++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index 42a4c4f..f9a1e6a 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -1,4 +1,4 @@ -import discord +import discord from redbot.core import commands, Config import psutil, datetime, json, aiohttp from aiohttp import web @@ -112,46 +112,43 @@ async def renamecluster(self, ctx, shard_id: int, *, new_name: str): await ctx.send(f"Cluster {shard_id} has been renamed to **{new_name}**.") - async def web_clusters(self, request): + async def web_clusters(self, request): """Return cluster data as JSON for web endpoint.""" await self.initialize_shard_names() + + virt_mem = psutil.virtual_memory() + swap_mem = psutil.swap_memory() + proc = psutil.Process() + bot_ram_gb = proc.memory_info().rss / 1024**3 - # Bot uptime bot_start_time = getattr(self.bot, "uptime", None) - if bot_start_time is None: - bot_uptime_str = "Unknown" - else: - if isinstance(bot_start_time, datetime.datetime): - td = datetime.datetime.utcnow() - bot_start_time - else: - td = bot_start_time - bot_uptime_str = self.format_timedelta(td) - + bot_uptime_str = self.format_timedelta(datetime.datetime.utcnow() - bot_start_time) if bot_start_time else "Unknown" server_uptime_str = self.format_timedelta(self.get_server_uptime()) data = { "bot_uptime": bot_uptime_str, "server_uptime": server_uptime_str, + "system_stats": { + "cpu_total_percent": psutil.cpu_percent(interval=None), + "ram_used_gb": round(virt_mem.used / 1024**3, 2), + "ram_total_gb": round(virt_mem.total / 1024**3, 2), + "bot_ram_gb": round(bot_ram_gb, 2), + "bot_ram_limit_gb": 10.0, + "swap_used_gb": round(swap_mem.used / 1024**3, 2), + "swap_total_gb": round(swap_mem.total / 1024**3, 2) + }, "clusters": [] } for shard_id, name in self.shard_names.items(): guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] - servers = len(guilds) - users = sum(g.member_count or 0 for g in guilds) - latency = round(self.bot.shards[shard_id].latency * 1000) - cpu = psutil.cpu_percent(interval=None) - ram = psutil.virtual_memory().used / 1024**3 - - cluster_data = { + data["clusters"].append({ "shard_id": shard_id, "name": name, - "servers": servers, - "users": users, - "latency_ms": latency, - "cpu_percent": cpu, - "ram_gib": ram - } - data["clusters"].append(cluster_data) - - return web.Response(text=json.dumps(data, indent=2), content_type="application/json") + "servers": len(guilds), + "users": sum(g.member_count or 0 for g in guilds), + "latency_ms": round(self.bot.shards[shard_id].latency * 1000) + }) + + return web.json_response(data) + From fb25675a00982f81831a4d59ad01d6a2c805157d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:46:50 +0000 Subject: [PATCH 37/67] Update clusters.py --- clusters/clusters.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index f9a1e6a..524711f 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -112,7 +112,7 @@ async def renamecluster(self, ctx, shard_id: int, *, new_name: str): await ctx.send(f"Cluster {shard_id} has been renamed to **{new_name}**.") - async def web_clusters(self, request): +async def web_clusters(self, request): """Return cluster data as JSON for web endpoint.""" await self.initialize_shard_names() @@ -151,4 +151,3 @@ async def web_clusters(self, request): }) return web.json_response(data) - From 576e8ac68e159d7a2b081b3f62038a049ef67a1b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:48:51 +0000 Subject: [PATCH 38/67] Update clusters.py --- clusters/clusters.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index 524711f..e878ffd 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -55,15 +55,11 @@ async def clusters(self, ctx): """Shows the status of all clusters using an embed.""" await self.initialize_shard_names() - # Bot uptime (Red tracks this as bot.uptime) bot_start_time = getattr(self.bot, "uptime", None) if bot_start_time is None: bot_uptime_str = "Unknown" else: - if isinstance(bot_start_time, datetime.datetime): - td = datetime.datetime.utcnow() - bot_start_time - else: - td = bot_start_time + td = datetime.datetime.utcnow() - bot_start_time if isinstance(bot_start_time, datetime.datetime) else bot_start_time bot_uptime_str = self.format_timedelta(td) server_uptime = self.format_timedelta(self.get_server_uptime()) @@ -75,24 +71,16 @@ async def clusters(self, ctx): ) for shard_id, name in self.shard_names.items(): - cpu = psutil.cpu_percent(interval=None) - ram = psutil.virtual_memory().used / 1024**3 latency = round(self.bot.shards[shard_id].latency * 1000) - guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] - servers = len(guilds) - users = sum(g.member_count or 0 for g in guilds) - + value = ( f"**Status:** Alive Running\n" - f"**CPU:** {cpu:.1f}%\n" - f"**RAM:** {ram:.1f} GiB\n" f"**Latency:** {latency} ms\n" - f"**Servers:** {servers}\n" - f"**Users:** {users}\n" + f"**Servers:** {len(guilds)}\n" + f"**Users:** {sum(g.member_count or 0 for g in guilds)}\n" f"**Shards:** [{shard_id}]" ) - embed.add_field(name=f"Cluster #{name}", value=value, inline=False) await ctx.send(embed=embed) @@ -109,10 +97,9 @@ async def renamecluster(self, ctx, shard_id: int, *, new_name: str): custom_names[str(shard_id)] = new_name await self.config.custom_names.set(custom_names) self.shard_names[shard_id] = new_name - await ctx.send(f"Cluster {shard_id} has been renamed to **{new_name}**.") -async def web_clusters(self, request): + async def web_clusters(self, request): """Return cluster data as JSON for web endpoint.""" await self.initialize_shard_names() @@ -150,4 +137,4 @@ async def web_clusters(self, request): "latency_ms": round(self.bot.shards[shard_id].latency * 1000) }) - return web.json_response(data) + return web.json_response(data) \ No newline at end of file From 68bc2917fdb9deabff6ec4e5be683358c7adb3e8 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:57:52 +0000 Subject: [PATCH 39/67] Update clusters.py --- clusters/clusters.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index e878ffd..36c7cfe 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -99,6 +99,7 @@ async def renamecluster(self, ctx, shard_id: int, *, new_name: str): self.shard_names[shard_id] = new_name await ctx.send(f"Cluster {shard_id} has been renamed to **{new_name}**.") + async def web_clusters(self, request): """Return cluster data as JSON for web endpoint.""" await self.initialize_shard_names() @@ -127,14 +128,24 @@ async def web_clusters(self, request): "clusters": [] } - for shard_id, name in self.shard_names.items(): + # Loop through the total shard count to ensure no shard is missing from JSON + total_shards = self.bot.shard_count or 1 + for shard_id in range(total_shards): + name = self.shard_names.get(shard_id, MARVEL_NAMES[shard_id % len(MARVEL_NAMES)]) + + # Get shard object safely + shard = self.bot.get_shard(shard_id) + latency = round(shard.latency * 1000) if (shard and shard.latency is not None) else 0 + guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] + data["clusters"].append({ "shard_id": shard_id, "name": name, "servers": len(guilds), "users": sum(g.member_count or 0 for g in guilds), - "latency_ms": round(self.bot.shards[shard_id].latency * 1000) + "latency_ms": latency, + "status": "Online" if (shard and not shard.is_closed()) else "Offline" }) return web.json_response(data) \ No newline at end of file From 9dcb8bc51bc98ab3f4ef16c7fdd96665c72ff611 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 00:03:39 +0000 Subject: [PATCH 40/67] Update clusters.py --- clusters/clusters.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/clusters/clusters.py b/clusters/clusters.py index 36c7cfe..3366823 100644 --- a/clusters/clusters.py +++ b/clusters/clusters.py @@ -128,15 +128,21 @@ async def web_clusters(self, request): "clusters": [] } - # Loop through the total shard count to ensure no shard is missing from JSON + # Use the bot's reported shard count total_shards = self.bot.shard_count or 1 for shard_id in range(total_shards): + # 1. Get name safely name = self.shard_names.get(shard_id, MARVEL_NAMES[shard_id % len(MARVEL_NAMES)]) - # Get shard object safely + # 2. Get shard object safely shard = self.bot.get_shard(shard_id) - latency = round(shard.latency * 1000) if (shard and shard.latency is not None) else 0 + # 3. Determine status and latency + # We explicitly check shard health to provide the 'status' key + is_online = shard is not None and not shard.is_closed() + latency = round(shard.latency * 1000) if (is_online and shard.latency is not None) else 0 + + # 4. Count guilds on this shard guilds = [g for g in self.bot.guilds if g.shard_id == shard_id] data["clusters"].append({ @@ -145,7 +151,7 @@ async def web_clusters(self, request): "servers": len(guilds), "users": sum(g.member_count or 0 for g in guilds), "latency_ms": latency, - "status": "Online" if (shard and not shard.is_closed()) else "Offline" + "status": "Online" if is_online else "Offline" }) return web.json_response(data) \ No newline at end of file From 41ee4dcbbc6c53931cd57cbbcab809a1437ff1fa Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 12:42:23 +0000 Subject: [PATCH 41/67] fix typo --- clusters/docs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clusters/docs.md b/clusters/docs.md index e118a0a..c53121d 100644 --- a/clusters/docs.md +++ b/clusters/docs.md @@ -43,7 +43,7 @@ for example this is my current output on my bot is a number between 0 and your mac amount of shards - what you want the cluter to be called from now on + what you want the cluster to be called from now on this is how it's used From 38b2533c27c29aa66967e11ead8a3762200c615f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:17:57 +0000 Subject: [PATCH 42/67] iss cog --- iss/__init__.py | 8 +++++ iss/iss.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 iss/__init__.py create mode 100644 iss/iss.py diff --git a/iss/__init__.py b/iss/__init__.py new file mode 100644 index 0000000..91e4e63 --- /dev/null +++ b/iss/__init__.py @@ -0,0 +1,8 @@ +from redbot.core.bot import Red + +from .iss import ISS + +__red_end_user_data_statement__ = "This cog does not store any end user data." + +async def setup(bot: Red): + await bot.add_cog(ISS(bot)) \ No newline at end of file diff --git a/iss/iss.py b/iss/iss.py new file mode 100644 index 0000000..60ac1a3 --- /dev/null +++ b/iss/iss.py @@ -0,0 +1,77 @@ +import discord +from redbot.core import commands +from lightstreamer.client import LightstreamerClient, Subscription +import logging + +log = logging.getLogger("red.issmimic") + +class ISSMimic(commands.Cog): + """Live ISS Telemetry Cog""" + + def __init__(self, bot): + self.bot = bot + self.ls_client = None + # Mapping IDs to readable names + self.telemetry_map = { + "USLAB000032": "Position X", + "USLAB000035": "Velocity (m/s)", + "USLAB000059": "Crew Count" + } + self.data_cache = {k: "Connecting..." for k in self.telemetry_map.keys()} + self.start_ls_client() + + def start_ls_client(self): + """Setup the Lightstreamer connection""" + try: + self.ls_client = LightstreamerClient("https://push.lightstreamer.com", "ISSLIVE") + + sub = Subscription( + mode="MERGE", + items=list(self.telemetry_map.keys()), + fields=["Value"] + ) + + # Internal listener to update the cache + class CogLSListener: + def __init__(self, cache): + self.cache = cache + def onItemUpdate(self, update): + item = update.getItemName() + val = update.getValue("Value") + # Rounding the long NASA decimals + try: + self.cache[item] = f"{float(val):,.2f}" + except ValueError: + self.cache[item] = val + + sub.addListener(CogLSListener(self.data_cache)) + self.ls_client.connect() + self.ls_client.subscribe(sub) + log.info("ISS-Mimic: Lightstreamer connected.") + except Exception as e: + log.error(f"ISS-Mimic: Failed to start Lightstreamer: {e}") + + def cog_unload(self): + """Stop the stream when the cog is unloaded""" + if self.ls_client: + self.ls_client.disconnect() + log.info("ISS-Mimic: Lightstreamer disconnected.") + + @commands.command() + async def iss(self, ctx): + """Get live telemetry from the ISS""" + embed = discord.Embed( + title="🛰️ ISS Live Telemetry", + color=discord.Color.dark_blue(), + description="Direct feed from NASA Mission Control" + ) + + for item_id, label in self.telemetry_map.items(): + value = self.data_cache.get(item_id, "N/A") + embed.add_field(name=label, value=f"`{value}`", inline=True) + + embed.set_footer(text="Data source: push.lightstreamer.com") + await ctx.send(embed=embed) + +async def setup(bot): + await bot.add_cog(ISSMimic(bot)) \ No newline at end of file From 06a7db5d015e946c04ef33551ff1c14a7e6a1682 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:19:46 +0000 Subject: [PATCH 43/67] fixes --- iss/iss.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 60ac1a3..ae43f75 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -3,9 +3,9 @@ from lightstreamer.client import LightstreamerClient, Subscription import logging -log = logging.getLogger("red.issmimic") +log = logging.getLogger("red.iss") -class ISSMimic(commands.Cog): +class ISS(commands.Cog): """Live ISS Telemetry Cog""" def __init__(self, bot): @@ -73,5 +73,3 @@ async def iss(self, ctx): embed.set_footer(text="Data source: push.lightstreamer.com") await ctx.send(embed=embed) -async def setup(bot): - await bot.add_cog(ISSMimic(bot)) \ No newline at end of file From 00a85c3647e5d999c05bd7722c244eb61c8d3a0c Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:21:42 +0000 Subject: [PATCH 44/67] more --- iss/iss.py | 70 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index ae43f75..5c462c2 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -3,73 +3,75 @@ from lightstreamer.client import LightstreamerClient, Subscription import logging -log = logging.getLogger("red.iss") +log = logging.getLogger("red.issmimic") class ISS(commands.Cog): - """Live ISS Telemetry Cog""" + """Full ISS-Mimic Live Telemetry Feed""" def __init__(self, bot): self.bot = bot self.ls_client = None - # Mapping IDs to readable names self.telemetry_map = { - "USLAB000032": "Position X", + "Z1000001": "Crew Count", + "USLAB000032": "Position X (km)", "USLAB000035": "Velocity (m/s)", - "USLAB000059": "Crew Count" + "USLAB000012": "Cabin Pressure", + "USLAB000013": "Internal Temp", + "USLAB000058": "Oxygen Level", + "USLAB000059": "Total Power (kW)", + "S4000001": "Solar Array 1A" } - self.data_cache = {k: "Connecting..." for k in self.telemetry_map.keys()} + self.data_cache = {k: "N/A" for k in self.telemetry_map.keys()} self.start_ls_client() def start_ls_client(self): - """Setup the Lightstreamer connection""" try: self.ls_client = LightstreamerClient("https://push.lightstreamer.com", "ISSLIVE") + sub = Subscription(mode="MERGE", items=list(self.telemetry_map.keys()), fields=["Value"]) - sub = Subscription( - mode="MERGE", - items=list(self.telemetry_map.keys()), - fields=["Value"] - ) - - # Internal listener to update the cache class CogLSListener: def __init__(self, cache): self.cache = cache def onItemUpdate(self, update): item = update.getItemName() val = update.getValue("Value") - # Rounding the long NASA decimals try: - self.cache[item] = f"{float(val):,.2f}" - except ValueError: + num = float(val) + if item == "Z1000001": # Integer for Crew + self.cache[item] = str(int(num)) + elif item == "USLAB000032": # Simple Vector to km conversion + self.cache[item] = f"{abs(num)/10:,.1f}" + elif "Temp" in self.cache.get(item, "") or item == "USLAB000013": + self.cache[item] = f"{num:,.1f} °C" + else: + self.cache[item] = f"{num:,.2f}" + except: self.cache[item] = val sub.addListener(CogLSListener(self.data_cache)) self.ls_client.connect() self.ls_client.subscribe(sub) - log.info("ISS-Mimic: Lightstreamer connected.") except Exception as e: - log.error(f"ISS-Mimic: Failed to start Lightstreamer: {e}") + log.error(f"ISS-Mimic: Failed: {e}") def cog_unload(self): - """Stop the stream when the cog is unloaded""" if self.ls_client: self.ls_client.disconnect() - log.info("ISS-Mimic: Lightstreamer disconnected.") @commands.command() async def iss(self, ctx): - """Get live telemetry from the ISS""" - embed = discord.Embed( - title="🛰️ ISS Live Telemetry", - color=discord.Color.dark_blue(), - description="Direct feed from NASA Mission Control" - ) + """View all live ISS-Mimic data points""" + embed = discord.Embed(title="🛰️ ISS Live Systems Feed", color=0x2b2d31) - for item_id, label in self.telemetry_map.items(): - value = self.data_cache.get(item_id, "N/A") - embed.add_field(name=label, value=f"`{value}`", inline=True) - - embed.set_footer(text="Data source: push.lightstreamer.com") - await ctx.send(embed=embed) - + # Grouping for better UI + gnc = f"**Vel:** `{self.data_cache['USLAB000035']} m/s`\n**Pos:** `{self.data_cache['USLAB000032']} km`" + eps = f"**Power:** `{self.data_cache['USLAB000059']} kW`\n**Array Angle:** `{self.data_cache['S4000001']}°`" + env = f"**Temp:** `{self.data_cache['USLAB000013']}`\n**O2:** `{self.data_cache['USLAB000058']}%`" + + embed.add_field(name="🚀 Navigation", value=gnc, inline=True) + embed.add_field(name="⚡ Electrical", value=eps, inline=True) + embed.add_field(name="🌡️ Environment", value=env, inline=True) + embed.add_field(name="👥 Crew", value=f"`{self.data_cache['Z1000001']}` souls onboard", inline=False) + + embed.set_footer(text="Data source: NASA Johnson Space Center (via Lightstreamer)") + await ctx.send(embed=embed) \ No newline at end of file From d8619b34e594dafb5f378a49c889c0378cef3ad2 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:23:25 +0000 Subject: [PATCH 45/67] Update iss.py --- iss/iss.py | 81 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 5c462c2..b6ef3a1 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -3,23 +3,39 @@ from lightstreamer.client import LightstreamerClient, Subscription import logging -log = logging.getLogger("red.issmimic") +log = logging.getLogger("red.iss") class ISS(commands.Cog): - """Full ISS-Mimic Live Telemetry Feed""" + """Full ISS-Mimic Live Systems & Russian Segment Feed""" def __init__(self, bot): self.bot = bot self.ls_client = None + # Complete mapping of data shown on the Mimic Dashboard self.telemetry_map = { - "Z1000001": "Crew Count", - "USLAB000032": "Position X (km)", - "USLAB000035": "Velocity (m/s)", - "USLAB000012": "Cabin Pressure", + # GNC / Navigation + "USLAB000032": "Pos X", + "USLAB000035": "Velocity", + # EPS / Power + "USLAB000059": "Total Power", + "S4000001": "Solar 1A", + "S4000002": "Solar 1B", + # ETHOS / Environment + "USLAB000012": "Pressure", "USLAB000013": "Internal Temp", - "USLAB000058": "Oxygen Level", - "USLAB000059": "Total Power (kW)", - "S4000001": "Solar Array 1A" + "USLAB000058": "O2 Level", + "USLAB000014": "Humidity", + "USLAB000015": "CO2 Level", + # CDH / Comms + "USLAB000080": "Video Link", + "USLAB000081": "Audio Link", + # EVA / Airlock + "AIRLOCK000049": "Airlock PSI", + # Russian Segment + "RUSSEG000001": "RS Pressure", + "RUSSEG000012": "RS Temp", + # Crew (Corrected ID) + "Z1000001": "Crew Count" } self.data_cache = {k: "N/A" for k in self.telemetry_map.keys()} self.start_ls_client() @@ -37,12 +53,19 @@ def onItemUpdate(self, update): val = update.getValue("Value") try: num = float(val) - if item == "Z1000001": # Integer for Crew - self.cache[item] = str(int(num)) - elif item == "USLAB000032": # Simple Vector to km conversion - self.cache[item] = f"{abs(num)/10:,.1f}" - elif "Temp" in self.cache.get(item, "") or item == "USLAB000013": - self.cache[item] = f"{num:,.1f} °C" + # --- DATA CORRECTION LOGIC --- + if item == "Z1000001": + self.cache[item] = str(int(num)) if num > 0 else "7" # Fallback to standard crew + elif item == "USLAB000058": # Fix Oxygen scaling + self.cache[item] = f"{(num / 10):,.1f}%" + elif item in ["USLAB000080", "USLAB000081"]: # Comms status + self.cache[item] = "🟢" if num > 0 else "🔴" + elif "Temp" in item or "RUSSEG000012" == item: + self.cache[item] = f"{num:,.1f}°C" + elif "Pressure" in item or "000012" in item: + self.cache[item] = f"{num:,.1f} psi" + elif item == "USLAB000032": + self.cache[item] = f"{abs(num)/10:,.1f} km" else: self.cache[item] = f"{num:,.2f}" except: @@ -52,7 +75,7 @@ def onItemUpdate(self, update): self.ls_client.connect() self.ls_client.subscribe(sub) except Exception as e: - log.error(f"ISS-Mimic: Failed: {e}") + log.error(f"ISS Error: {e}") def cog_unload(self): if self.ls_client: @@ -60,18 +83,22 @@ def cog_unload(self): @commands.command() async def iss(self, ctx): - """View all live ISS-Mimic data points""" - embed = discord.Embed(title="🛰️ ISS Live Systems Feed", color=0x2b2d31) + """View the full live telemetry suite from the ISS""" + embed = discord.Embed(title="🛰️ ISS Live Systems Command Center", color=0x2b2d31) - # Grouping for better UI - gnc = f"**Vel:** `{self.data_cache['USLAB000035']} m/s`\n**Pos:** `{self.data_cache['USLAB000032']} km`" - eps = f"**Power:** `{self.data_cache['USLAB000059']} kW`\n**Array Angle:** `{self.data_cache['S4000001']}°`" - env = f"**Temp:** `{self.data_cache['USLAB000013']}`\n**O2:** `{self.data_cache['USLAB000058']}%`" + # Navigation & Russian Segment + nav = f"**Vel:** `{self.data_cache['USLAB000035']} m/s`\n**Alt:** `{self.data_cache['USLAB000032']}`\n**RS Temp:** `{self.data_cache['RUSSEG000012']}`" + # Environment + env = f"**O2:** `{self.data_cache['USLAB000058']}`\n**CO2:** `{self.data_cache['USLAB000015']} mmHg`\n**Hum:** `{self.data_cache['USLAB000014']}%`" + # Comms & EVA + status = f"**Video:** {self.data_cache['USLAB000080']}\n**Audio:** {self.data_cache['USLAB000081']}\n**Airlock:** `{self.data_cache['AIRLOCK000049']}`" - embed.add_field(name="🚀 Navigation", value=gnc, inline=True) - embed.add_field(name="⚡ Electrical", value=eps, inline=True) - embed.add_field(name="🌡️ Environment", value=env, inline=True) - embed.add_field(name="👥 Crew", value=f"`{self.data_cache['Z1000001']}` souls onboard", inline=False) + embed.add_field(name="🚀 Orbital / RS", value=nav, inline=True) + embed.add_field(name="🌡️ ETHOS (Life Support)", value=env, inline=True) + embed.add_field(name="📡 Comms & EVA", value=status, inline=True) - embed.set_footer(text="Data source: NASA Johnson Space Center (via Lightstreamer)") + # Power Bar + embed.add_field(name="⚡ SPARTAN (Power)", value=f"**Total:** `{self.data_cache['USLAB000059']} kW` | **1A:** `{self.data_cache['S4000001']}°` | **1B:** `{self.data_cache['S4000002']}°`", inline=False) + + embed.set_footer(text=f"👥 Crew Onboard: {self.data_cache['Z1000001']} | Data: NASA Lightstreamer") await ctx.send(embed=embed) \ No newline at end of file From 3f77435201ea953d752b0674ead709cf682ac4fa Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:27:01 +0000 Subject: [PATCH 46/67] more --- iss/iss.py | 128 ++++++++++++++++++++------------------------- iss/telemetry.json | 36 +++++++++++++ 2 files changed, 92 insertions(+), 72 deletions(-) create mode 100644 iss/telemetry.json diff --git a/iss/iss.py b/iss/iss.py index b6ef3a1..cf02c6f 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -1,104 +1,88 @@ import discord +import json +import logging +from pathlib import Path from redbot.core import commands from lightstreamer.client import LightstreamerClient, Subscription -import logging log = logging.getLogger("red.iss") class ISS(commands.Cog): - """Full ISS-Mimic Live Systems & Russian Segment Feed""" + """The Complete ISS-Mimic Telemetry Suite""" def __init__(self, bot): self.bot = bot self.ls_client = None - # Complete mapping of data shown on the Mimic Dashboard - self.telemetry_map = { - # GNC / Navigation - "USLAB000032": "Pos X", - "USLAB000035": "Velocity", - # EPS / Power - "USLAB000059": "Total Power", - "S4000001": "Solar 1A", - "S4000002": "Solar 1B", - # ETHOS / Environment - "USLAB000012": "Pressure", - "USLAB000013": "Internal Temp", - "USLAB000058": "O2 Level", - "USLAB000014": "Humidity", - "USLAB000015": "CO2 Level", - # CDH / Comms - "USLAB000080": "Video Link", - "USLAB000081": "Audio Link", - # EVA / Airlock - "AIRLOCK000049": "Airlock PSI", - # Russian Segment - "RUSSEG000001": "RS Pressure", - "RUSSEG000012": "RS Temp", - # Crew (Corrected ID) - "Z1000001": "Crew Count" - } - self.data_cache = {k: "N/A" for k in self.telemetry_map.keys()} + self.data_cache = {} + + # Load telemetry map from separate JSON file + cog_path = Path(__file__).parent + with open(cog_path / "telemetry.json", "r") as f: + self.telemetry_map = json.load(f) + + # Flatten IDs for the subscription + self.all_ids = [] + for category in self.telemetry_map.values(): + self.all_ids.extend(category.keys()) + + self.data_cache = {k: "Connecting..." for k in self.all_ids} self.start_ls_client() def start_ls_client(self): try: self.ls_client = LightstreamerClient("https://push.lightstreamer.com", "ISSLIVE") - sub = Subscription(mode="MERGE", items=list(self.telemetry_map.keys()), fields=["Value"]) + sub = Subscription(mode="MERGE", items=self.all_ids, fields=["Value"]) - class CogLSListener: - def __init__(self, cache): - self.cache = cache + class LSListener: + def __init__(self, cache): self.cache = cache def onItemUpdate(self, update): - item = update.getItemName() - val = update.getValue("Value") + item, val = update.getItemName(), update.getValue("Value") try: num = float(val) - # --- DATA CORRECTION LOGIC --- - if item == "Z1000001": - self.cache[item] = str(int(num)) if num > 0 else "7" # Fallback to standard crew - elif item == "USLAB000058": # Fix Oxygen scaling - self.cache[item] = f"{(num / 10):,.1f}%" - elif item in ["USLAB000080", "USLAB000081"]: # Comms status - self.cache[item] = "🟢" if num > 0 else "🔴" - elif "Temp" in item or "RUSSEG000012" == item: - self.cache[item] = f"{num:,.1f}°C" - elif "Pressure" in item or "000012" in item: - self.cache[item] = f"{num:,.1f} psi" - elif item == "USLAB000032": - self.cache[item] = f"{abs(num)/10:,.1f} km" - else: - self.cache[item] = f"{num:,.2f}" + # Advanced Formatting Logic + if "Voltage" in item: self.cache[item] = f"{num:.3f}V" + elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.cache[item] = f"{num:.2f}°" + elif "Pressure" in item or "torr" in item: self.cache[item] = f"{num:.1f} mmHg" + elif "Temp" in item: self.cache[item] = f"{num:.1f}°C" + elif "Mass" in item: self.cache[item] = f"{num:,.0f} kg" + else: self.cache[item] = f"{num:,.2f}" except: - self.cache[item] = val + self.cache[item] = val # Keep as string (e.g. "ACTIVE", "DOCKING") - sub.addListener(CogLSListener(self.data_cache)) + sub.addListener(LSListener(self.data_cache)) self.ls_client.connect() self.ls_client.subscribe(sub) except Exception as e: - log.error(f"ISS Error: {e}") + log.error(f"ISS Mimic Connection Failure: {e}") def cog_unload(self): if self.ls_client: self.ls_client.disconnect() - @commands.command() + @commands.group(invoke_without_command=True) async def iss(self, ctx): - """View the full live telemetry suite from the ISS""" - embed = discord.Embed(title="🛰️ ISS Live Systems Command Center", color=0x2b2d31) - - # Navigation & Russian Segment - nav = f"**Vel:** `{self.data_cache['USLAB000035']} m/s`\n**Alt:** `{self.data_cache['USLAB000032']}`\n**RS Temp:** `{self.data_cache['RUSSEG000012']}`" - # Environment - env = f"**O2:** `{self.data_cache['USLAB000058']}`\n**CO2:** `{self.data_cache['USLAB000015']} mmHg`\n**Hum:** `{self.data_cache['USLAB000014']}%`" - # Comms & EVA - status = f"**Video:** {self.data_cache['USLAB000080']}\n**Audio:** {self.data_cache['USLAB000081']}\n**Airlock:** `{self.data_cache['AIRLOCK000049']}`" - - embed.add_field(name="🚀 Orbital / RS", value=nav, inline=True) - embed.add_field(name="🌡️ ETHOS (Life Support)", value=env, inline=True) - embed.add_field(name="📡 Comms & EVA", value=status, inline=True) - - # Power Bar - embed.add_field(name="⚡ SPARTAN (Power)", value=f"**Total:** `{self.data_cache['USLAB000059']} kW` | **1A:** `{self.data_cache['S4000001']}°` | **1B:** `{self.data_cache['S4000002']}°`", inline=False) + """ISS Telemetry Hub. Use [p]iss all or specific categories.""" + await ctx.send_help() + + @iss.command(name="all") + async def iss_all(self, ctx): + """View a summary of all major ISS-Mimic systems""" + embed = discord.Embed(title="🛰️ ISS Systems: Master Feed", color=0x2b2d31) - embed.set_footer(text=f"👥 Crew Onboard: {self.data_cache['Z1000001']} | Data: NASA Lightstreamer") - await ctx.send(embed=embed) \ No newline at end of file + for category, sensors in self.telemetry_map.items(): + lines = [] + for id_key, label in sensors.items(): + val = self.data_cache.get(id_key, "N/A") + lines.append(f"**{label}:** `{val}`") + + # Group into embed fields + embed.add_field(name=f"__**{category}**__", value="\n".join(lines), inline=True) + + embed.set_footer(text="Data: NASA/JSC via Lightstreamer (Real-time)") + await ctx.send(embed=embed) + + @iss.command(name="gnc") + async def iss_gnc(self, ctx): + """Guidance, Navigation, and Control details""" + # Logic for a focused view of just one category + pass diff --git a/iss/telemetry.json b/iss/telemetry.json new file mode 100644 index 0000000..2e3a291 --- /dev/null +++ b/iss/telemetry.json @@ -0,0 +1,36 @@ +{ + "GNC": { + "USLAB000ALT": "Altitude (km)", + "USLAB000035": "Velocity (m/s)", + "USLAB000PIT": "Pitch", + "USLAB000YAW": "Yaw", + "USLAB000ROL": "Roll", + "USLAB000039": "Station Mass (kg)" + }, + "ETHOS": { + "USLAB000058": "Cabin Pressure", + "USLAB000059": "Cabin Temp", + "NODE3000005": "Urine Tank %", + "NODE3000009": "Clean Water %", + "USLAB000015": "CO2 Level", + "AIRLOCK000049": "Airlock Pressure" + }, + "SPARTAN": { + "USLAB000059": "Total Power", + "S4000001": "Solar 1A BGA", + "S4000002": "Solar 1B BGA", + "VVOS0000004": "Port SARJ Angle", + "VVOS0000003": "Stbd SARJ Angle" + }, + "ROBOTICS": { + "AMT000001": "MT Position", + "SSRMS004": "Arm Shoulder Roll", + "SSRMS007": "Arm Elbow Pitch", + "SSRMS011": "Tip Payload Status" + }, + "RUSSIAN": { + "RUSSEG000001": "RS Mode", + "RUSSEG000013": "Fwd Docking", + "RUSSEG000021": "RS Attitude Mode" + } +} \ No newline at end of file From b892534f9c470756a2720220d20927b4f9d8325d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:30:03 +0000 Subject: [PATCH 47/67] more --- iss/iss.py | 91 ++++++++++++++++++++++++++++++++-------------- iss/telemetry.json | 45 +++++++++++++++++------ 2 files changed, 98 insertions(+), 38 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index cf02c6f..924df40 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -1,6 +1,7 @@ import discord import json import logging +import datetime from pathlib import Path from redbot.core import commands from lightstreamer.client import LightstreamerClient, Subscription @@ -8,19 +9,18 @@ log = logging.getLogger("red.iss") class ISS(commands.Cog): - """The Complete ISS-Mimic Telemetry Suite""" + """The Complete ISS-Mimic Telemetry Suite with Categorized Views""" def __init__(self, bot): self.bot = bot self.ls_client = None - self.data_cache = {} + self.last_update = None - # Load telemetry map from separate JSON file + # Load telemetry map cog_path = Path(__file__).parent with open(cog_path / "telemetry.json", "r") as f: self.telemetry_map = json.load(f) - # Flatten IDs for the subscription self.all_ids = [] for category in self.telemetry_map.values(): self.all_ids.extend(category.keys()) @@ -34,22 +34,22 @@ def start_ls_client(self): sub = Subscription(mode="MERGE", items=self.all_ids, fields=["Value"]) class LSListener: - def __init__(self, cache): self.cache = cache + def __init__(self, outer): self.outer = outer def onItemUpdate(self, update): item, val = update.getItemName(), update.getValue("Value") + self.outer.last_update = datetime.datetime.now(datetime.timezone.utc) try: num = float(val) - # Advanced Formatting Logic - if "Voltage" in item: self.cache[item] = f"{num:.3f}V" - elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.cache[item] = f"{num:.2f}°" - elif "Pressure" in item or "torr" in item: self.cache[item] = f"{num:.1f} mmHg" - elif "Temp" in item: self.cache[item] = f"{num:.1f}°C" - elif "Mass" in item: self.cache[item] = f"{num:,.0f} kg" - else: self.cache[item] = f"{num:,.2f}" + if "Voltage" in item: self.outer.data_cache[item] = f"{num:.3f}V" + elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.outer.data_cache[item] = f"{num:.2f}°" + elif "Pressure" in item or "torr" in item: self.outer.data_cache[item] = f"{num:.1f} mmHg" + elif "Temp" in item: self.outer.data_cache[item] = f"{num:.1f}°C" + elif "Mass" in item: self.outer.data_cache[item] = f"{num:,.0f} kg" + else: self.outer.data_cache[item] = f"{num:,.2f}" except: - self.cache[item] = val # Keep as string (e.g. "ACTIVE", "DOCKING") + self.outer.data_cache[item] = val - sub.addListener(LSListener(self.data_cache)) + sub.addListener(LSListener(self)) self.ls_client.connect() self.ls_client.subscribe(sub) except Exception as e: @@ -59,30 +59,67 @@ def cog_unload(self): if self.ls_client: self.ls_client.disconnect() + async def build_category_embed(self, category_key: str, title: str, color: int): + """Standardizes the look of all category commands""" + sensors = self.telemetry_map.get(category_key) + if not sensors: + return discord.Embed(description="Category not found.", color=discord.Color.red()) + + embed = discord.Embed(title=title, color=color) + lines = [f"**{label}:** `{self.data_cache.get(id_k, 'N/A')}`" for id_k, label in sensors.items()] + embed.description = "\n".join(lines) + + if self.last_update: + timestamp = self.last_update.strftime("%H:%M:%S UTC") + embed.set_footer(text=f"Last NASA Update: {timestamp} | Signal: Acquired 🟢") + else: + embed.set_footer(text="Signal: Waiting for Data... 🔴") + return embed + @commands.group(invoke_without_command=True) async def iss(self, ctx): - """ISS Telemetry Hub. Use [p]iss all or specific categories.""" + """ISS Telemetry Command Hub""" await ctx.send_help() @iss.command(name="all") async def iss_all(self, ctx): """View a summary of all major ISS-Mimic systems""" embed = discord.Embed(title="🛰️ ISS Systems: Master Feed", color=0x2b2d31) - for category, sensors in self.telemetry_map.items(): - lines = [] - for id_key, label in sensors.items(): - val = self.data_cache.get(id_key, "N/A") - lines.append(f"**{label}:** `{val}`") - - # Group into embed fields + lines = [f"**{label}:** `{self.data_cache.get(id_k, 'N/A')}`" for id_k, label in sensors.items()] embed.add_field(name=f"__**{category}**__", value="\n".join(lines), inline=True) - - embed.set_footer(text="Data: NASA/JSC via Lightstreamer (Real-time)") + + if self.last_update: + embed.set_footer(text=f"Real-time Data Active • Last Update: {self.last_update.strftime('%H:%M:%S')}") await ctx.send(embed=embed) @iss.command(name="gnc") async def iss_gnc(self, ctx): - """Guidance, Navigation, and Control details""" - # Logic for a focused view of just one category - pass + """Guidance, Navigation, and Control""" + embed = await self.build_category_embed("GNC", "🚀 Orbital GNC Status", 0x2ecc71) + await ctx.send(embed=embed) + + @iss.command(name="ethos") + async def iss_ethos(self, ctx): + """Life Support & Environmental Systems""" + embed = await self.build_category_embed("ETHOS", "🌡️ ETHOS Systems", 0x3498db) + await ctx.send(embed=embed) + + @iss.command(name="power") + async def iss_power(self, ctx): + """Electrical Power (SPARTAN)""" + embed = await self.build_category_embed("SPARTAN", "⚡ Power Management", 0xf1c40f) + await ctx.send(embed=embed) + + @iss.command(name="robotics") + async def iss_robotics(self, ctx): + """Robotics & SSRMS""" + embed = await self.build_category_embed("ROBOTICS", "🦾 Robotics Status", 0xe67e22) + await ctx.send(embed=embed) + + @iss.command(name="russian") + async def iss_russian(self, ctx): + """Russian Segment Telemetry""" + embed = await self.build_category_embed("RUSSIAN", "🇷🇺 Russian Segment (RS)", 0xe74c3c) + await ctx.send(embed=embed) + diff --git a/iss/telemetry.json b/iss/telemetry.json index 2e3a291..4b9cd18 100644 --- a/iss/telemetry.json +++ b/iss/telemetry.json @@ -5,32 +5,55 @@ "USLAB000PIT": "Pitch", "USLAB000YAW": "Yaw", "USLAB000ROL": "Roll", - "USLAB000039": "Station Mass (kg)" + "USLAB000039": "Station Mass (kg)", + "USLAB000005": "CMGs Online", + "USLAB000010": "CMG Momentum %" }, "ETHOS": { "USLAB000058": "Cabin Pressure", "USLAB000059": "Cabin Temp", + "USLAB000014": "Humidity %", + "USLAB000015": "CO2 Level", "NODE3000005": "Urine Tank %", "NODE3000009": "Clean Water %", - "USLAB000015": "CO2 Level", + "NODE3000010": "O2 Gen State", "AIRLOCK000049": "Airlock Pressure" }, "SPARTAN": { - "USLAB000059": "Total Power", - "S4000001": "Solar 1A BGA", - "S4000002": "Solar 1B BGA", + "USLAB000059": "Total Power (kW)", + "S4000007": "Solar 1A Beta", + "S6000008": "Solar 1B Beta", + "P4000007": "Solar 2A Beta", + "P6000008": "Solar 2B Beta", "VVOS0000004": "Port SARJ Angle", "VVOS0000003": "Stbd SARJ Angle" }, "ROBOTICS": { - "AMT000001": "MT Position", - "SSRMS004": "Arm Shoulder Roll", - "SSRMS007": "Arm Elbow Pitch", + "AMT000001": "MT Position (cm)", + "SSRMS004": "Shoulder Roll", + "SSRMS005": "Shoulder Yaw", + "SSRMS006": "Shoulder Pitch", + "SSRMS007": "Elbow Pitch", "SSRMS011": "Tip Payload Status" }, + "COMMS": { + "USLAB000088": "Ku-Video Ch1", + "USLAB000089": "Ku-Video Ch2", + "USLAB000101": "UHF Radio Sync", + "USLAB000092": "S-Band Active String", + "Z1000013": "Ku-Band Transmit" + }, "RUSSIAN": { - "RUSSEG000001": "RS Mode", - "RUSSEG000013": "Fwd Docking", - "RUSSEG000021": "RS Attitude Mode" + "RUSSEG000001": "RS Station Mode", + "RUSSEG000013": "Fwd Docking Port", + "RUSSEG000014": "Aft Docking Port", + "RUSSEG000021": "RS Attitude Mode", + "RUSSEG000025": "RS Dynamic Mode" + }, + "EVA": { + "AIRLOCK000001": "Suit 1 Voltage", + "AIRLOCK000003": "Suit 2 Voltage", + "AIRLOCK000011": "Battery Charger 1", + "AIRLOCK000055": "O2 Tank Pressure" } } \ No newline at end of file From fd7b77b4512605d4899c3d829b99bca4c16b1551 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:32:16 +0000 Subject: [PATCH 48/67] more go brrrrrrrrrrrr --- iss/iss.py | 79 +++++++++++++++++++++------------------------- iss/telemetry.json | 60 ++++++++++++++++++++++++----------- 2 files changed, 77 insertions(+), 62 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 924df40..abdfa28 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -9,14 +9,14 @@ log = logging.getLogger("red.iss") class ISS(commands.Cog): - """The Complete ISS-Mimic Telemetry Suite with Categorized Views""" + """The Maximum Detail ISS-Mimic Suite""" def __init__(self, bot): self.bot = bot self.ls_client = None self.last_update = None - # Load telemetry map + # Load the expanded telemetry mapping cog_path = Path(__file__).parent with open(cog_path / "telemetry.json", "r") as f: self.telemetry_map = json.load(f) @@ -40,11 +40,10 @@ def onItemUpdate(self, update): self.outer.last_update = datetime.datetime.now(datetime.timezone.utc) try: num = float(val) - if "Voltage" in item: self.outer.data_cache[item] = f"{num:.3f}V" - elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.outer.data_cache[item] = f"{num:.2f}°" - elif "Pressure" in item or "torr" in item: self.outer.data_cache[item] = f"{num:.1f} mmHg" - elif "Temp" in item: self.outer.data_cache[item] = f"{num:.1f}°C" - elif "Mass" in item: self.outer.data_cache[item] = f"{num:,.0f} kg" + # High-precision formatting for voltage and mass + if "Voltage" in item: self.outer.data_cache[item] = f"{num:.3f}" + elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.outer.data_cache[item] = f"{num:.2f}" + elif "Mass" in item: self.outer.data_cache[item] = f"{num:,.0f}" else: self.outer.data_cache[item] = f"{num:,.2f}" except: self.outer.data_cache[item] = val @@ -56,24 +55,22 @@ def onItemUpdate(self, update): log.error(f"ISS Mimic Connection Failure: {e}") def cog_unload(self): - if self.ls_client: - self.ls_client.disconnect() - - async def build_category_embed(self, category_key: str, title: str, color: int): - """Standardizes the look of all category commands""" - sensors = self.telemetry_map.get(category_key) - if not sensors: - return discord.Embed(description="Category not found.", color=discord.Color.red()) + if self.ls_client: self.ls_client.disconnect() + async def build_embed(self, category_keys: list, title: str, color: int): + """Builds an embed supporting multiple JSON categories""" embed = discord.Embed(title=title, color=color) - lines = [f"**{label}:** `{self.data_cache.get(id_k, 'N/A')}`" for id_k, label in sensors.items()] - embed.description = "\n".join(lines) + + for key in category_keys: + sensors = self.telemetry_map.get(key, {}) + lines = [f"**{label}:** `{self.data_cache.get(id_k, '...')}`" for id_k, label in sensors.items()] + if lines: + # Add sub-categories as fields to avoid the 2048 character limit + embed.add_field(name=f"📊 {key.replace('_', ' ')}", value="\n".join(lines), inline=True) if self.last_update: - timestamp = self.last_update.strftime("%H:%M:%S UTC") - embed.set_footer(text=f"Last NASA Update: {timestamp} | Signal: Acquired 🟢") - else: - embed.set_footer(text="Signal: Waiting for Data... 🔴") + ts = self.last_update.strftime("%H:%M:%S UTC") + embed.set_footer(text=f"Last NASA Update: {ts} | Signal: Acquired 🟢") return embed @commands.group(invoke_without_command=True) @@ -83,43 +80,39 @@ async def iss(self, ctx): @iss.command(name="all") async def iss_all(self, ctx): - """View a summary of all major ISS-Mimic systems""" - embed = discord.Embed(title="🛰️ ISS Systems: Master Feed", color=0x2b2d31) - for category, sensors in self.telemetry_map.items(): - lines = [f"**{label}:** `{self.data_cache.get(id_k, 'N/A')}`" for id_k, label in sensors.items()] - embed.add_field(name=f"__**{category}**__", value="\n".join(lines), inline=True) - - if self.last_update: - embed.set_footer(text=f"Real-time Data Active • Last Update: {self.last_update.strftime('%H:%M:%S')}") - await ctx.send(embed=embed) + """The Complete Station Overview""" + # Split into two embeds to avoid character limits + e1 = await self.build_embed(["GNC", "ETHOS_AIR", "ETHOS_WATER"], "🛰️ ISS Primary Systems", 0x2b2d31) + e2 = await self.build_embed(["SPARTAN_POWER", "ROBOTICS", "RUSSIAN"], "🛰️ ISS Engineering & RS", 0x2b2d31) + await ctx.send(embed=e1) + await ctx.send(embed=e2) @iss.command(name="gnc") async def iss_gnc(self, ctx): """Guidance, Navigation, and Control""" - embed = await self.build_category_embed("GNC", "🚀 Orbital GNC Status", 0x2ecc71) + embed = await self.build_embed(["GNC"], "🚀 Orbital GNC Status", 0x2ecc71) await ctx.send(embed=embed) @iss.command(name="ethos") async def iss_ethos(self, ctx): - """Life Support & Environmental Systems""" - embed = await self.build_category_embed("ETHOS", "🌡️ ETHOS Systems", 0x3498db) + """Environment & Water Recovery""" + embed = await self.build_embed(["ETHOS_AIR", "ETHOS_WATER"], "🌡️ Life Support Systems", 0x3498db) await ctx.send(embed=embed) - @iss.command(name="power") - async def iss_power(self, ctx): - """Electrical Power (SPARTAN)""" - embed = await self.build_category_embed("SPARTAN", "⚡ Power Management", 0xf1c40f) + @iss.command(name="eva") + async def iss_eva(self, ctx): + """Airlock, Suits, and Battery Chargers""" + embed = await self.build_embed(["EVA_SUITS", "EVA_CHARGERS"], "👨‍🚀 Spacewalk Telemetry", 0x9b59b6) await ctx.send(embed=embed) @iss.command(name="robotics") async def iss_robotics(self, ctx): - """Robotics & SSRMS""" - embed = await self.build_category_embed("ROBOTICS", "🦾 Robotics Status", 0xe67e22) + """Canadarm2 Full Joint Data""" + embed = await self.build_embed(["ROBOTICS"], "🦾 SSRMS Robotics", 0xe67e22) await ctx.send(embed=embed) @iss.command(name="russian") async def iss_russian(self, ctx): - """Russian Segment Telemetry""" - embed = await self.build_category_embed("RUSSIAN", "🇷🇺 Russian Segment (RS)", 0xe74c3c) - await ctx.send(embed=embed) - + """Russian Segment Propulsion & Docking""" + embed = await self.build_embed(["RUSSIAN"], "🇷🇺 Russian Segment", 0xe74c3c) + await ctx.send(embed=embed) \ No newline at end of file diff --git a/iss/telemetry.json b/iss/telemetry.json index 4b9cd18..bb96f04 100644 --- a/iss/telemetry.json +++ b/iss/telemetry.json @@ -7,24 +7,36 @@ "USLAB000ROL": "Roll", "USLAB000039": "Station Mass (kg)", "USLAB000005": "CMGs Online", - "USLAB000010": "CMG Momentum %" + "USLAB000010": "CMG Momentum %", + "USLAB000011": "CMG 1 Status", + "USLAB000012": "CMG 2 Status", + "USLAB000013": "CMG 3 Status", + "USLAB000014": "CMG 4 Status" }, - "ETHOS": { + "ETHOS_AIR": { "USLAB000058": "Cabin Pressure", "USLAB000059": "Cabin Temp", "USLAB000014": "Humidity %", "USLAB000015": "CO2 Level", + "NODE3000001": "Node 3 O2 PP", + "NODE3000003": "Node 3 CO2 PP", + "NODE3000010": "O2 Gen State", + "USLAB000010": "N2 Supply Press" + }, + "ETHOS_WATER": { "NODE3000005": "Urine Tank %", "NODE3000009": "Clean Water %", - "NODE3000010": "O2 Gen State", - "AIRLOCK000049": "Airlock Pressure" + "NODE3000015": "WRS Pump State", + "NODE3000017": "Potable Water Bus" }, - "SPARTAN": { + "SPARTAN_POWER": { "USLAB000059": "Total Power (kW)", "S4000007": "Solar 1A Beta", "S6000008": "Solar 1B Beta", "P4000007": "Solar 2A Beta", "P6000008": "Solar 2B Beta", + "S4000001": "Solar 3A Beta", + "S6000002": "Solar 3B Beta", "VVOS0000004": "Port SARJ Angle", "VVOS0000003": "Stbd SARJ Angle" }, @@ -34,26 +46,36 @@ "SSRMS005": "Shoulder Yaw", "SSRMS006": "Shoulder Pitch", "SSRMS007": "Elbow Pitch", + "SSRMS008": "Wrist Pitch", + "SSRMS009": "Wrist Yaw", + "SSRMS010": "Wrist Roll", "SSRMS011": "Tip Payload Status" }, - "COMMS": { - "USLAB000088": "Ku-Video Ch1", - "USLAB000089": "Ku-Video Ch2", - "USLAB000101": "UHF Radio Sync", - "USLAB000092": "S-Band Active String", - "Z1000013": "Ku-Band Transmit" + "EVA_SUITS": { + "AIRLOCK000001": "EMU 1 Voltage", + "AIRLOCK000002": "EMU 1 Current", + "AIRLOCK000003": "EMU 2 Voltage", + "AIRLOCK000004": "EMU 2 Current", + "AIRLOCK000049": "Crewlock Pressure", + "AIRLOCK000055": "High P O2 Tank", + "AIRLOCK000056": "Low P O2 Tank" + }, + "EVA_CHARGERS": { + "AIRLOCK000011": "BCA 1 Voltage", + "AIRLOCK000012": "BCA 1 Current", + "AIRLOCK000023": "BCA 1 Ch 1 Status", + "AIRLOCK000024": "BCA 1 Ch 2 Status", + "AIRLOCK000025": "BCA 1 Ch 3 Status", + "AIRLOCK000026": "BCA 1 Ch 4 Status", + "AIRLOCK000013": "BCA 2 Voltage", + "AIRLOCK000014": "BCA 2 Current" }, "RUSSIAN": { - "RUSSEG000001": "RS Station Mode", + "RUSSEG000001": "RS Mode", "RUSSEG000013": "Fwd Docking Port", "RUSSEG000014": "Aft Docking Port", "RUSSEG000021": "RS Attitude Mode", - "RUSSEG000025": "RS Dynamic Mode" - }, - "EVA": { - "AIRLOCK000001": "Suit 1 Voltage", - "AIRLOCK000003": "Suit 2 Voltage", - "AIRLOCK000011": "Battery Charger 1", - "AIRLOCK000055": "O2 Tank Pressure" + "RUSSEG000025": "RS Dynamic Mode", + "RUSSEG000011": "SM Propulsion" } } \ No newline at end of file From 4a41590c6425952518cbc8f927c53fedd3e09c0f Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:34:43 +0000 Subject: [PATCH 49/67] iss status command --- iss/iss.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/iss/iss.py b/iss/iss.py index abdfa28..f2b2222 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -115,4 +115,40 @@ async def iss_robotics(self, ctx): async def iss_russian(self, ctx): """Russian Segment Propulsion & Docking""" embed = await self.build_embed(["RUSSIAN"], "🇷🇺 Russian Segment", 0xe74c3c) + await ctx.send(embed=embed) + + + @iss.command(name="status") + async def iss_status(self, ctx): + """Check which sensors are currently broadcasting live data""" + now = time.time() + active_sensors = [] + inactive_count = 0 + + for id_k in self.all_ids: + last_seen = self.last_item_update.get(id_k, 0) + if (now - last_seen) < 60: # Active in the last 60 seconds + label = "Unknown" + # Find label in JSON + for cat in self.telemetry_map.values(): + if id_k in cat: + label = cat[id_k] + break + active_sensors.append(f"🟢 **{label}** ({id_k})") + else: + inactive_count += 1 + + embed = discord.Embed(title="📡 Sensor Activity Report", color=0x2ecc71) + + if active_sensors: + # Show top 15 active sensors (to avoid too much text) + display_list = active_sensors[:15] + embed.description = "**Active Sensors (Last 60s):**\n" + "\n".join(display_list) + if len(active_sensors) > 15: + embed.description += f"\n*...and {len(active_sensors)-15} more active.*" + else: + embed.description = "⚠️ **No sensors active in the last 60 seconds.**\nThe ISS may be in a Loss of Signal (LOS) period." + + embed.add_field(name="Summary", value=f"✅ Active: `{len(active_sensors)}` | 💤 Standby: `{inactive_count}`") + embed.set_footer(text=f"Check [p]iss all for raw data") await ctx.send(embed=embed) \ No newline at end of file From 3a7c58071a1be697c53bd1a37b8ad56f01e128d1 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:35:34 +0000 Subject: [PATCH 50/67] forgot to import time --- iss/iss.py | 1 + 1 file changed, 1 insertion(+) diff --git a/iss/iss.py b/iss/iss.py index f2b2222..f018e46 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -2,6 +2,7 @@ import json import logging import datetime +import time from pathlib import Path from redbot.core import commands from lightstreamer.client import LightstreamerClient, Subscription From 302c5fe493136a66d012631980c12469d63d4d5b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:36:51 +0000 Subject: [PATCH 51/67] Update iss.py --- iss/iss.py | 121 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 68 insertions(+), 53 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index f018e46..4fc99b1 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -10,14 +10,15 @@ log = logging.getLogger("red.iss") class ISS(commands.Cog): - """The Maximum Detail ISS-Mimic Suite""" + """The Complete ISS-Mimic Telemetry Suite""" def __init__(self, bot): self.bot = bot self.ls_client = None self.last_update = None + self.last_item_update = {} # Correctly initialized to prevent AttributeErrors - # Load the expanded telemetry mapping + # Load the expanded telemetry mapping from telemetry.json cog_path = Path(__file__).parent with open(cog_path / "telemetry.json", "r") as f: self.telemetry_map = json.load(f) @@ -26,27 +27,41 @@ def __init__(self, bot): for category in self.telemetry_map.values(): self.all_ids.extend(category.keys()) - self.data_cache = {k: "Connecting..." for k in self.all_ids} + # Use "---" as default to keep the UI clean while waiting for data + self.data_cache = {k: "---" for k in self.all_ids} self.start_ls_client() def start_ls_client(self): + """Initializes connection to NASA's Lightstreamer server""" try: self.ls_client = LightstreamerClient("https://push.lightstreamer.com", "ISSLIVE") sub = Subscription(mode="MERGE", items=self.all_ids, fields=["Value"]) class LSListener: - def __init__(self, outer): self.outer = outer + def __init__(self, outer): + self.outer = outer + def onItemUpdate(self, update): - item, val = update.getItemName(), update.getValue("Value") - self.outer.last_update = datetime.datetime.now(datetime.timezone.utc) + item = update.getItemName() + val = update.getValue("Value") + now = time.time() + + # Update global and per-item timestamps + self.outer.last_update = datetime.datetime.fromtimestamp(now, datetime.timezone.utc) + self.outer.last_item_update[item] = now + try: num = float(val) - # High-precision formatting for voltage and mass - if "Voltage" in item: self.outer.data_cache[item] = f"{num:.3f}" - elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.outer.data_cache[item] = f"{num:.2f}" - elif "Mass" in item: self.outer.data_cache[item] = f"{num:,.0f}" - else: self.outer.data_cache[item] = f"{num:,.2f}" - except: + # Specific formatting for cleaner output + if "Voltage" in item: + self.outer.data_cache[item] = f"{num:.3f}" + elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): + self.outer.data_cache[item] = f"{num:.2f}" + elif "Mass" in item: + self.outer.data_cache[item] = f"{num:,.0f}" + else: + self.outer.data_cache[item] = f"{num:,.2f}" + except (ValueError, TypeError): self.outer.data_cache[item] = val sub.addListener(LSListener(self)) @@ -56,17 +71,19 @@ def onItemUpdate(self, update): log.error(f"ISS Mimic Connection Failure: {e}") def cog_unload(self): - if self.ls_client: self.ls_client.disconnect() + """Disconnects the stream when the cog is unloaded""" + if self.ls_client: + self.ls_client.disconnect() async def build_embed(self, category_keys: list, title: str, color: int): - """Builds an embed supporting multiple JSON categories""" + """Helper to build telemetry embeds from specific JSON categories""" embed = discord.Embed(title=title, color=color) for key in category_keys: sensors = self.telemetry_map.get(key, {}) - lines = [f"**{label}:** `{self.data_cache.get(id_k, '...')}`" for id_k, label in sensors.items()] + lines = [f"**{label}:** `{self.data_cache.get(id_k, '---')}`" for id_k, label in sensors.items()] if lines: - # Add sub-categories as fields to avoid the 2048 character limit + # Group data by sub-category fields embed.add_field(name=f"📊 {key.replace('_', ' ')}", value="\n".join(lines), inline=True) if self.last_update: @@ -81,13 +98,46 @@ async def iss(self, ctx): @iss.command(name="all") async def iss_all(self, ctx): - """The Complete Station Overview""" - # Split into two embeds to avoid character limits + """The Complete Station Overview (Paginated)""" + # Split into two messages to respect Discord's character limit e1 = await self.build_embed(["GNC", "ETHOS_AIR", "ETHOS_WATER"], "🛰️ ISS Primary Systems", 0x2b2d31) e2 = await self.build_embed(["SPARTAN_POWER", "ROBOTICS", "RUSSIAN"], "🛰️ ISS Engineering & RS", 0x2b2d31) await ctx.send(embed=e1) await ctx.send(embed=e2) + @iss.command(name="status") + async def iss_status(self, ctx): + """Check which sensors are currently broadcasting live data""" + now = time.time() + active_sensors = [] + inactive_count = 0 + + for id_k in self.all_ids: + last_seen = self.last_item_update.get(id_k, 0) + if (now - last_seen) < 60: # Updated within the last minute + # Find the label for display + label = "Unknown" + for cat in self.telemetry_map.values(): + if id_k in cat: + label = cat[id_k] + break + active_sensors.append(f"🟢 **{label}**") + else: + inactive_count += 1 + + embed = discord.Embed(title="📡 Data Stream Status", color=0x2ecc71 if active_sensors else 0xe74c3c) + + if active_sensors: + display = active_sensors[:15] + embed.description = "**Active Sensors (Live):**\n" + "\n".join(display) + if len(active_sensors) > 15: + embed.description += f"\n*...and {len(active_sensors)-15} more active.*" + else: + embed.description = "⚠️ **No sensors active in the last 60 seconds.**\nThe ISS may be in a Loss of Signal (LOS) period." + + embed.add_field(name="Summary", value=f"✅ Active: `{len(active_sensors)}` | 💤 Standby: `{inactive_count}`") + await ctx.send(embed=embed) + @iss.command(name="gnc") async def iss_gnc(self, ctx): """Guidance, Navigation, and Control""" @@ -118,38 +168,3 @@ async def iss_russian(self, ctx): embed = await self.build_embed(["RUSSIAN"], "🇷🇺 Russian Segment", 0xe74c3c) await ctx.send(embed=embed) - - @iss.command(name="status") - async def iss_status(self, ctx): - """Check which sensors are currently broadcasting live data""" - now = time.time() - active_sensors = [] - inactive_count = 0 - - for id_k in self.all_ids: - last_seen = self.last_item_update.get(id_k, 0) - if (now - last_seen) < 60: # Active in the last 60 seconds - label = "Unknown" - # Find label in JSON - for cat in self.telemetry_map.values(): - if id_k in cat: - label = cat[id_k] - break - active_sensors.append(f"🟢 **{label}** ({id_k})") - else: - inactive_count += 1 - - embed = discord.Embed(title="📡 Sensor Activity Report", color=0x2ecc71) - - if active_sensors: - # Show top 15 active sensors (to avoid too much text) - display_list = active_sensors[:15] - embed.description = "**Active Sensors (Last 60s):**\n" + "\n".join(display_list) - if len(active_sensors) > 15: - embed.description += f"\n*...and {len(active_sensors)-15} more active.*" - else: - embed.description = "⚠️ **No sensors active in the last 60 seconds.**\nThe ISS may be in a Loss of Signal (LOS) period." - - embed.add_field(name="Summary", value=f"✅ Active: `{len(active_sensors)}` | 💤 Standby: `{inactive_count}`") - embed.set_footer(text=f"Check [p]iss all for raw data") - await ctx.send(embed=embed) \ No newline at end of file From 04e35f0fe19175ae41319b6274ed74805651896d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:37:59 +0000 Subject: [PATCH 52/67] more tweaks --- iss/iss.py | 126 ++++++++++++++++++----------------------------------- 1 file changed, 43 insertions(+), 83 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 4fc99b1..4d21c90 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -10,15 +10,14 @@ log = logging.getLogger("red.iss") class ISS(commands.Cog): - """The Complete ISS-Mimic Telemetry Suite""" + """J.A.R.V.I.S. ISS Command Center - Final Build""" def __init__(self, bot): self.bot = bot self.ls_client = None self.last_update = None - self.last_item_update = {} # Correctly initialized to prevent AttributeErrors + self.last_item_update = {} - # Load the expanded telemetry mapping from telemetry.json cog_path = Path(__file__).parent with open(cog_path / "telemetry.json", "r") as f: self.telemetry_map = json.load(f) @@ -27,12 +26,10 @@ def __init__(self, bot): for category in self.telemetry_map.values(): self.all_ids.extend(category.keys()) - # Use "---" as default to keep the UI clean while waiting for data - self.data_cache = {k: "---" for k in self.all_ids} + self.data_cache = {k: "Connecting..." for k in self.all_ids} self.start_ls_client() def start_ls_client(self): - """Initializes connection to NASA's Lightstreamer server""" try: self.ls_client = LightstreamerClient("https://push.lightstreamer.com", "ISSLIVE") sub = Subscription(mode="MERGE", items=self.all_ids, fields=["Value"]) @@ -46,23 +43,21 @@ def onItemUpdate(self, update): val = update.getValue("Value") now = time.time() - # Update global and per-item timestamps self.outer.last_update = datetime.datetime.fromtimestamp(now, datetime.timezone.utc) self.outer.last_item_update[item] = now + if val is None: + return + try: num = float(val) - # Specific formatting for cleaner output - if "Voltage" in item: - self.outer.data_cache[item] = f"{num:.3f}" - elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): - self.outer.data_cache[item] = f"{num:.2f}" - elif "Mass" in item: - self.outer.data_cache[item] = f"{num:,.0f}" - else: - self.outer.data_cache[item] = f"{num:,.2f}" + # Smart Units & Precision + if "Voltage" in item: self.outer.data_cache[item] = f"{num:.3f}" + elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.outer.data_cache[item] = f"{num:.2f}" + elif "Mass" in item: self.outer.data_cache[item] = f"{num:,.0f}" + else: self.outer.data_cache[item] = f"{num:,.2f}" except (ValueError, TypeError): - self.outer.data_cache[item] = val + self.outer.data_cache[item] = str(val) sub.addListener(LSListener(self)) self.ls_client.connect() @@ -71,24 +66,32 @@ def onItemUpdate(self, update): log.error(f"ISS Mimic Connection Failure: {e}") def cog_unload(self): - """Disconnects the stream when the cog is unloaded""" if self.ls_client: self.ls_client.disconnect() async def build_embed(self, category_keys: list, title: str, color: int): - """Helper to build telemetry embeds from specific JSON categories""" embed = discord.Embed(title=title, color=color) + now = time.time() for key in category_keys: sensors = self.telemetry_map.get(key, {}) - lines = [f"**{label}:** `{self.data_cache.get(id_k, '---')}`" for id_k, label in sensors.items()] - if lines: - # Group data by sub-category fields - embed.add_field(name=f"📊 {key.replace('_', ' ')}", value="\n".join(lines), inline=True) + lines = [] + active_in_cat = False + + for id_k, label in sensors.items(): + val = self.data_cache.get(id_k, "Connecting...") + # Mark active sensors with a small dot + is_active = (now - self.last_item_update.get(id_k, 0)) < 60 + prefix = "🔹 " if is_active else "" + lines.append(f"{prefix}**{label}:** `{val}`") + if is_active: active_in_cat = True + + status_emoji = "🟢" if active_in_cat else "💤" + embed.add_field(name=f"{status_emoji} {key.replace('_', ' ')}", value="\n".join(lines), inline=True) if self.last_update: ts = self.last_update.strftime("%H:%M:%S UTC") - embed.set_footer(text=f"Last NASA Update: {ts} | Signal: Acquired 🟢") + embed.set_footer(text=f"NASA Stream: {ts} | Signal: Acquired 📡") return embed @commands.group(invoke_without_command=True) @@ -98,73 +101,30 @@ async def iss(self, ctx): @iss.command(name="all") async def iss_all(self, ctx): - """The Complete Station Overview (Paginated)""" - # Split into two messages to respect Discord's character limit - e1 = await self.build_embed(["GNC", "ETHOS_AIR", "ETHOS_WATER"], "🛰️ ISS Primary Systems", 0x2b2d31) - e2 = await self.build_embed(["SPARTAN_POWER", "ROBOTICS", "RUSSIAN"], "🛰️ ISS Engineering & RS", 0x2b2d31) + """Station Overview (Dual-Module Feed)""" + e1 = await self.build_embed(["GNC", "ETHOS_AIR", "ETHOS_WATER"], "🛰️ Primary Systems", 0x2b2d31) + e2 = await self.build_embed(["SPARTAN_POWER", "ROBOTICS", "RUSSIAN"], "🛰️ Engineering & Logistics", 0x2b2d31) await ctx.send(embed=e1) await ctx.send(embed=e2) @iss.command(name="status") async def iss_status(self, ctx): - """Check which sensors are currently broadcasting live data""" + """Sensor Activity Report""" now = time.time() - active_sensors = [] - inactive_count = 0 + active = [id_k for id_k in self.all_ids if (now - self.last_item_update.get(id_k, 0)) < 60] - for id_k in self.all_ids: - last_seen = self.last_item_update.get(id_k, 0) - if (now - last_seen) < 60: # Updated within the last minute - # Find the label for display - label = "Unknown" - for cat in self.telemetry_map.values(): - if id_k in cat: - label = cat[id_k] - break - active_sensors.append(f"🟢 **{label}**") - else: - inactive_count += 1 - - embed = discord.Embed(title="📡 Data Stream Status", color=0x2ecc71 if active_sensors else 0xe74c3c) + embed = discord.Embed(title="📡 Data Stream Health", color=0x2ecc71 if active else 0xe74c3c) + embed.add_field(name="Summary", value=f"✅ Active: `{len(active)}` | 💤 Standby: `{len(self.all_ids)-len(active)}`", inline=False) - if active_sensors: - display = active_sensors[:15] - embed.description = "**Active Sensors (Live):**\n" + "\n".join(display) - if len(active_sensors) > 15: - embed.description += f"\n*...and {len(active_sensors)-15} more active.*" + if active: + # Get first 15 names + names = [] + for id_k in active[:15]: + for cat in self.telemetry_map.values(): + if id_k in cat: names.append(f"🟢 {cat[id_k]}") + embed.description = "**Currently Streaming:**\n" + "\n".join(names) else: - embed.description = "⚠️ **No sensors active in the last 60 seconds.**\nThe ISS may be in a Loss of Signal (LOS) period." - - embed.add_field(name="Summary", value=f"✅ Active: `{len(active_sensors)}` | 💤 Standby: `{inactive_count}`") - await ctx.send(embed=embed) - - @iss.command(name="gnc") - async def iss_gnc(self, ctx): - """Guidance, Navigation, and Control""" - embed = await self.build_embed(["GNC"], "🚀 Orbital GNC Status", 0x2ecc71) - await ctx.send(embed=embed) - - @iss.command(name="ethos") - async def iss_ethos(self, ctx): - """Environment & Water Recovery""" - embed = await self.build_embed(["ETHOS_AIR", "ETHOS_WATER"], "🌡️ Life Support Systems", 0x3498db) - await ctx.send(embed=embed) - - @iss.command(name="eva") - async def iss_eva(self, ctx): - """Airlock, Suits, and Battery Chargers""" - embed = await self.build_embed(["EVA_SUITS", "EVA_CHARGERS"], "👨‍🚀 Spacewalk Telemetry", 0x9b59b6) - await ctx.send(embed=embed) - - @iss.command(name="robotics") - async def iss_robotics(self, ctx): - """Canadarm2 Full Joint Data""" - embed = await self.build_embed(["ROBOTICS"], "🦾 SSRMS Robotics", 0xe67e22) - await ctx.send(embed=embed) - - @iss.command(name="russian") - async def iss_russian(self, ctx): - """Russian Segment Propulsion & Docking""" - embed = await self.build_embed(["RUSSIAN"], "🇷🇺 Russian Segment", 0xe74c3c) + embed.description = "⚠️ No active data. Station may be in LOS (Loss of Signal)." + await ctx.send(embed=embed) From 6648ac04d487ae5954a114ab792627aa52708ee9 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:40:45 +0000 Subject: [PATCH 53/67] Update iss.py --- iss/iss.py | 75 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 4d21c90..6859141 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -9,8 +9,37 @@ log = logging.getLogger("red.iss") +# --- UI Components for Choosing Categories --- + +class CategorySelect(discord.ui.Select): + def __init__(self, cog): + self.cog = cog + # These values must match the keys in your telemetry.json exactly + options = [ + discord.SelectOption(label="Primary GNC", value="GNC", description="Altitude, Velocity, Attitude", emoji="🚀"), + discord.SelectOption(label="Air Systems", value="ETHOS_AIR", description="Pressure, Temp, CO2", emoji="🌬️"), + discord.SelectOption(label="Water Systems", value="ETHOS_WATER", description="Urine Tank, Clean Water", emoji="💧"), + discord.SelectOption(label="Power Status", value="SPARTAN_POWER", description="Solar Arrays & SARJ", emoji="⚡"), + discord.SelectOption(label="Robotics", value="ROBOTICS", description="Canadarm2 Joint Data", emoji="🦾"), + discord.SelectOption(label="EVA / Airlock", value="EVA_SUITS", description="Suit Voltages & Pressure", emoji="👨‍🚀"), + discord.SelectOption(label="Russian Segment", value="RUSSIAN", description="Docking & RS Mode", emoji="🇷🇺"), + ] + super().__init__(placeholder="Select a system to monitor...", options=options) + + async def callback(self, interaction: discord.Interaction): + # build_embed expects a list of keys + embed = await self.cog.build_embed([self.values[0]], f"🛰️ {self.values[0]} Telemetry", 0x2b2d31) + await interaction.response.send_message(embed=embed, ephemeral=True) + +class SelectionView(discord.ui.View): + def __init__(self, cog): + super().__init__(timeout=60) + self.add_item(CategorySelect(cog)) + +# --- Main Cog --- + class ISS(commands.Cog): - """J.A.R.V.I.S. ISS Command Center - Final Build""" + """J.A.R.V.I.S. ISS Command Center - Interactive Build""" def __init__(self, bot): self.bot = bot @@ -42,16 +71,12 @@ def onItemUpdate(self, update): item = update.getItemName() val = update.getValue("Value") now = time.time() - self.outer.last_update = datetime.datetime.fromtimestamp(now, datetime.timezone.utc) self.outer.last_item_update[item] = now - if val is None: - return - + if val is None: return try: num = float(val) - # Smart Units & Precision if "Voltage" in item: self.outer.data_cache[item] = f"{num:.3f}" elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.outer.data_cache[item] = f"{num:.2f}" elif "Mass" in item: self.outer.data_cache[item] = f"{num:,.0f}" @@ -66,26 +91,21 @@ def onItemUpdate(self, update): log.error(f"ISS Mimic Connection Failure: {e}") def cog_unload(self): - if self.ls_client: - self.ls_client.disconnect() + if self.ls_client: self.ls_client.disconnect() async def build_embed(self, category_keys: list, title: str, color: int): embed = discord.Embed(title=title, color=color) now = time.time() - for key in category_keys: sensors = self.telemetry_map.get(key, {}) lines = [] active_in_cat = False - for id_k, label in sensors.items(): val = self.data_cache.get(id_k, "Connecting...") - # Mark active sensors with a small dot is_active = (now - self.last_item_update.get(id_k, 0)) < 60 prefix = "🔹 " if is_active else "" lines.append(f"{prefix}**{label}:** `{val}`") if is_active: active_in_cat = True - status_emoji = "🟢" if active_in_cat else "💤" embed.add_field(name=f"{status_emoji} {key.replace('_', ' ')}", value="\n".join(lines), inline=True) @@ -97,7 +117,9 @@ async def build_embed(self, category_keys: list, title: str, color: int): @commands.group(invoke_without_command=True) async def iss(self, ctx): """ISS Telemetry Command Hub""" - await ctx.send_help() + # This now sends the interactive "Choice" menu + view = SelectionView(self) + await ctx.send("📡 **Interactive ISS Telemetry Console**\nChoose a category below to view live data:", view=view) @iss.command(name="all") async def iss_all(self, ctx): @@ -107,17 +129,34 @@ async def iss_all(self, ctx): await ctx.send(embed=e1) await ctx.send(embed=e2) + # --- Added Individual Category Commands --- + + @iss.command(name="gnc") + async def iss_gnc(self, ctx): + """Guidance, Navigation, and Control""" + embed = await self.build_embed(["GNC"], "🚀 Orbital GNC Status", 0x2ecc71) + await ctx.send(embed=embed) + + @iss.command(name="ethos") + async def iss_ethos(self, ctx): + """Environment & Life Support""" + embed = await self.build_embed(["ETHOS_AIR", "ETHOS_WATER"], "🌡️ Life Support Systems", 0x3498db) + await ctx.send(embed=embed) + + @iss.command(name="robotics") + async def iss_robotics(self, ctx): + """Canadarm2 Status""" + embed = await self.build_embed(["ROBOTICS"], "🦾 SSRMS Robotics", 0xe67e22) + await ctx.send(embed=embed) + @iss.command(name="status") async def iss_status(self, ctx): """Sensor Activity Report""" now = time.time() active = [id_k for id_k in self.all_ids if (now - self.last_item_update.get(id_k, 0)) < 60] - embed = discord.Embed(title="📡 Data Stream Health", color=0x2ecc71 if active else 0xe74c3c) embed.add_field(name="Summary", value=f"✅ Active: `{len(active)}` | 💤 Standby: `{len(self.all_ids)-len(active)}`", inline=False) - if active: - # Get first 15 names names = [] for id_k in active[:15]: for cat in self.telemetry_map.values(): @@ -125,6 +164,4 @@ async def iss_status(self, ctx): embed.description = "**Currently Streaming:**\n" + "\n".join(names) else: embed.description = "⚠️ No active data. Station may be in LOS (Loss of Signal)." - - await ctx.send(embed=embed) - + await ctx.send(embed=embed) \ No newline at end of file From 969763b9ab365d7d7eaaf43fad217fc20d4abe8a Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:42:14 +0000 Subject: [PATCH 54/67] Update iss.py --- iss/iss.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iss/iss.py b/iss/iss.py index 6859141..37a5292 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -29,7 +29,7 @@ def __init__(self, cog): async def callback(self, interaction: discord.Interaction): # build_embed expects a list of keys embed = await self.cog.build_embed([self.values[0]], f"🛰️ {self.values[0]} Telemetry", 0x2b2d31) - await interaction.response.send_message(embed=embed, ephemeral=True) + await interaction.response.send_message(embed=embed, ephemeral=False) class SelectionView(discord.ui.View): def __init__(self, cog): From 1245d241974b2a01fb0218db8d144979f0bf99cb Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:46:49 +0000 Subject: [PATCH 55/67] docs and also stuff for the red index --- iss/docs.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++ iss/info.json | 12 ++++++++ 2 files changed, 90 insertions(+) create mode 100644 iss/docs.md create mode 100644 iss/info.json diff --git a/iss/docs.md b/iss/docs.md new file mode 100644 index 0000000..4d14f66 --- /dev/null +++ b/iss/docs.md @@ -0,0 +1,78 @@ +\# 🛰️ J.A.R.V.I.S. ISS-Mimic Telemetry Suite + + + +This documentation covers the setup, operation, and troubleshooting of the ISS-Mimic Discord Cog. This system bridges NASA's public Lightstreamer telemetry feed directly into your Discord server. + + + +--- + + + +\## 🛠️ Core Commands + + + +| Command | Usage | Description | + +| :--- | :--- | :--- | + +| `\*iss` | `\*iss` | \*\*Interactive Console\*\*: Opens a public dropdown menu to browse telemetry by category. | + +| `\*iss all` | `\*iss all` | \*\*Master Snapshot\*\*: Sends two massive embeds containing every tracked sensor in the system. | + +| `\*iss status` | `\*iss status` | \*\*Stream Health\*\*: Checks which sensors are currently broadcasting live data (updated in the last 60s). | + +| `\*iss \[cat]` | `\*iss gnc` | \*\*Quick View\*\*: Direct access to specific categories (gnc, ethos, robotics, russian, etc.). | + + + +--- + + + +\## 🚦 Understanding Sensor States + + + +Because NASA uses \*\*MERGE\*\* mode for data transmission, sensors will show different states based on station activity: + + + +\* \*\*🟢 Active (Green Circle)\*\*: Data has been received for this system within the last 60 seconds. + +\* \*\*💤 Standby (Zzz Emoji)\*\*: The system is subscribed, but the value hasn't changed recently, so NASA isn't pushing updates. + +\* \*\*🔹 Blue Diamond\*\*: Appears next to individual sensors that are currently streaming live data. + +\* \*\*--- / Connecting\*\*: The bot is connected but waiting for the very first data packet to arrive for that specific ID. + + + + + + + +--- + + + +\## 📂 Configuration (`telemetry.json`) + + + +The Cog is entirely data-driven. To add or rename sensors, edit the `telemetry.json` file. + + + +\*\*Format:\*\* + +```json + +"CATEGORY\_NAME": { + +  "NASA\_OPCODE": "Display Label" + +} + diff --git a/iss/info.json b/iss/info.json new file mode 100644 index 0000000..fcefbba --- /dev/null +++ b/iss/info.json @@ -0,0 +1,12 @@ +{ + "name": "ISS", + "author": [ + "bencos17" + ], + "description": "A cog to track the International Space Station's telemetry data and provide information about its current location, speed, altitude, and other relevant data.", + "requirements": [], + "tags": [ + "issdata", + "iss" + ] +} \ No newline at end of file From d97d98dcfd5c144aaff0f085ac1e4a4aba4508ae Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:51:25 +0000 Subject: [PATCH 56/67] Update iss.py --- iss/iss.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/iss/iss.py b/iss/iss.py index 37a5292..0e9525f 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -164,4 +164,31 @@ async def iss_status(self, ctx): embed.description = "**Currently Streaming:**\n" + "\n".join(names) else: embed.description = "⚠️ No active data. Station may be in LOS (Loss of Signal)." - await ctx.send(embed=embed) \ No newline at end of file + await ctx.send(embed=embed) + + + + @iss.command(name="reconnect") + @commands.is_owner() + async def iss_reconnect(self, ctx): + """Restart the NASA Lightstreamer connection (Owner Only)""" + await ctx.send("🔄 Resetting telemetry link...") + if self.ls_client: self.ls_client.disconnect() + self.start_ls_client() + await ctx.send("✅ Connection re-established.") + + @iss.command(name="discover") + @commands.is_owner() + async def iss_discover(self, ctx): + """Paginated list of untracked NASA Opcodes (Owner Only)""" + active_raw_ids = list(self.last_item_update.keys()) + missing = sorted([id for id in active_raw_ids if id not in self.all_ids]) + + if not missing: + return await ctx.send("✅ No untracked IDs found.") + + pages = [] + for page in pagify("\n".join(missing), page_length=1000): + pages.append(box(page, lang="text")) + + await menu(ctx, pages, DEFAULT_CONTROLS) \ No newline at end of file From cc1fdfa8b84b000eb187cd67694deaa5c5a3f66b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:58:08 +0000 Subject: [PATCH 57/67] Update iss.py --- iss/iss.py | 54 +++++++++++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 0e9525f..687a166 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -3,18 +3,18 @@ import logging import datetime import time +import asyncio from pathlib import Path from redbot.core import commands +from redbot.core.utils.chat_formatting import box, pagify +from redbot.core.utils.menus import menu, DEFAULT_CONTROLS from lightstreamer.client import LightstreamerClient, Subscription log = logging.getLogger("red.iss") -# --- UI Components for Choosing Categories --- - class CategorySelect(discord.ui.Select): def __init__(self, cog): self.cog = cog - # These values must match the keys in your telemetry.json exactly options = [ discord.SelectOption(label="Primary GNC", value="GNC", description="Altitude, Velocity, Attitude", emoji="🚀"), discord.SelectOption(label="Air Systems", value="ETHOS_AIR", description="Pressure, Temp, CO2", emoji="🌬️"), @@ -27,8 +27,8 @@ def __init__(self, cog): super().__init__(placeholder="Select a system to monitor...", options=options) async def callback(self, interaction: discord.Interaction): - # build_embed expects a list of keys embed = await self.cog.build_embed([self.values[0]], f"🛰️ {self.values[0]} Telemetry", 0x2b2d31) + # Ephemeral=False ensures the response is public await interaction.response.send_message(embed=embed, ephemeral=False) class SelectionView(discord.ui.View): @@ -36,8 +36,6 @@ def __init__(self, cog): super().__init__(timeout=60) self.add_item(CategorySelect(cog)) -# --- Main Cog --- - class ISS(commands.Cog): """J.A.R.V.I.S. ISS Command Center - Interactive Build""" @@ -46,6 +44,7 @@ def __init__(self, bot): self.ls_client = None self.last_update = None self.last_item_update = {} + self.discovered_ids = set() cog_path = Path(__file__).parent with open(cog_path / "telemetry.json", "r") as f: @@ -74,6 +73,9 @@ def onItemUpdate(self, update): self.outer.last_update = datetime.datetime.fromtimestamp(now, datetime.timezone.utc) self.outer.last_item_update[item] = now + if item not in self.outer.all_ids: + self.outer.discovered_ids.add(item) + if val is None: return try: num = float(val) @@ -103,11 +105,12 @@ async def build_embed(self, category_keys: list, title: str, color: int): for id_k, label in sensors.items(): val = self.data_cache.get(id_k, "Connecting...") is_active = (now - self.last_item_update.get(id_k, 0)) < 60 + if is_active: active_in_cat = True prefix = "🔹 " if is_active else "" lines.append(f"{prefix}**{label}:** `{val}`") - if is_active: active_in_cat = True + status_emoji = "🟢" if active_in_cat else "💤" - embed.add_field(name=f"{status_emoji} {key.replace('_', ' ')}", value="\n".join(lines), inline=True) + embed.add_field(name=f"{status_emoji} {key.replace('_', ' ')}", value="\n".join(lines) or "No Data", inline=True) if self.last_update: ts = self.last_update.strftime("%H:%M:%S UTC") @@ -117,7 +120,6 @@ async def build_embed(self, category_keys: list, title: str, color: int): @commands.group(invoke_without_command=True) async def iss(self, ctx): """ISS Telemetry Command Hub""" - # This now sends the interactive "Choice" menu view = SelectionView(self) await ctx.send("📡 **Interactive ISS Telemetry Console**\nChoose a category below to view live data:", view=view) @@ -129,8 +131,6 @@ async def iss_all(self, ctx): await ctx.send(embed=e1) await ctx.send(embed=e2) - # --- Added Individual Category Commands --- - @iss.command(name="gnc") async def iss_gnc(self, ctx): """Guidance, Navigation, and Control""" @@ -166,29 +166,33 @@ async def iss_status(self, ctx): embed.description = "⚠️ No active data. Station may be in LOS (Loss of Signal)." await ctx.send(embed=embed) - - @iss.command(name="reconnect") @commands.is_owner() async def iss_reconnect(self, ctx): - """Restart the NASA Lightstreamer connection (Owner Only)""" + """Restart the NASA link (Owner Only)""" await ctx.send("🔄 Resetting telemetry link...") if self.ls_client: self.ls_client.disconnect() self.start_ls_client() await ctx.send("✅ Connection re-established.") + @iss.command(name="scan") + @commands.is_owner() + async def iss_scan(self, ctx): + """Hunt for untracked NASA Opcodes (Owner Only)""" + await ctx.send("🛰️ **Scanning NASA broad-range telemetry...** (30s scan)") + prefixes = ["USLAB", "NODE1", "NODE2", "NODE3", "AIRLOCK", "SSRMS", "S1", "P1"] + test_ids = [f"{p}{str(i).zfill(7)}" for p in prefixes for i in range(1, 20)] + scan_sub = Subscription(mode="MERGE", items=test_ids, fields=["Value"]) + self.ls_client.subscribe(scan_sub) + await asyncio.sleep(30) + self.ls_client.unsubscribe(scan_sub) + await ctx.send(f"✅ Scan complete. Found `{len(self.discovered_ids)}` new Opcodes. Use `*iss discover` to view.") + @iss.command(name="discover") @commands.is_owner() async def iss_discover(self, ctx): - """Paginated list of untracked NASA Opcodes (Owner Only)""" - active_raw_ids = list(self.last_item_update.keys()) - missing = sorted([id for id in active_raw_ids if id not in self.all_ids]) - - if not missing: - return await ctx.send("✅ No untracked IDs found.") - - pages = [] - for page in pagify("\n".join(missing), page_length=1000): - pages.append(box(page, lang="text")) - + """Show untracked IDs caught by the scanner (Owner Only)""" + if not self.discovered_ids: + return await ctx.send("❌ No untracked IDs detected. Try running `*iss scan` first.") + pages = [box(p, lang="text") for p in pagify("\n".join(sorted(list(self.discovered_ids))), page_length=1000)] await menu(ctx, pages, DEFAULT_CONTROLS) \ No newline at end of file From 8bfddd3c715dd1620d95340d8218c81348b2421a Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:36:28 +0000 Subject: [PATCH 58/67] Update telemetry.json --- iss/telemetry.json | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/iss/telemetry.json b/iss/telemetry.json index bb96f04..6a69a77 100644 --- a/iss/telemetry.json +++ b/iss/telemetry.json @@ -8,37 +8,39 @@ "USLAB000039": "Station Mass (kg)", "USLAB000005": "CMGs Online", "USLAB000010": "CMG Momentum %", - "USLAB000011": "CMG 1 Status", - "USLAB000012": "CMG 2 Status", - "USLAB000013": "CMG 3 Status", - "USLAB000014": "CMG 4 Status" + "USLAB000001": "CMG 1 Status", + "USLAB000002": "CMG 2 Status", + "USLAB000003": "CMG 3 Status", + "USLAB000004": "CMG 4 Status" }, "ETHOS_AIR": { "USLAB000058": "Cabin Pressure", "USLAB000059": "Cabin Temp", - "USLAB000014": "Humidity %", - "USLAB000015": "CO2 Level", + "USLAB000054": "Lab ppN2", + "USLAB000053": "Lab ppO2", "NODE3000001": "Node 3 O2 PP", "NODE3000003": "Node 3 CO2 PP", "NODE3000010": "O2 Gen State", - "USLAB000010": "N2 Supply Press" + "AIRLOCK000052": "N2 Supply Valve Position" }, "ETHOS_WATER": { "NODE3000005": "Urine Tank %", "NODE3000009": "Clean Water %", - "NODE3000015": "WRS Pump State", - "NODE3000017": "Potable Water Bus" + "NODE3000008": "Waste Water Tank %", + "NODE3000004": "Urine Processor State", + "NODE3000006": "Water Processor State", + "NODE3000017": "Coolant Water Node 3" }, "SPARTAN_POWER": { - "USLAB000059": "Total Power (kW)", + "USLAB000059": "Cabin Temperature (Internal)", "S4000007": "Solar 1A Beta", "S6000008": "Solar 1B Beta", "P4000007": "Solar 2A Beta", "P6000008": "Solar 2B Beta", "S4000001": "Solar 3A Beta", "S6000002": "Solar 3B Beta", - "VVOS0000004": "Port SARJ Angle", - "VVOS0000003": "Stbd SARJ Angle" + "S0000004": "Port SARJ Angle", + "S0000003": "Stbd SARJ Angle" }, "ROBOTICS": { "AMT000001": "MT Position (cm)", @@ -60,6 +62,14 @@ "AIRLOCK000055": "High P O2 Tank", "AIRLOCK000056": "Low P O2 Tank" }, + "EVA_POWER": { + "AIRLOCK000007": "EMU 1 Power Voltage", + "AIRLOCK000008": "EMU 1 Power Current", + "AIRLOCK000009": "EMU 2 Power Voltage", + "AIRLOCK000010": "EMU 2 Power Current", + "AIRLOCK000005": "IRU Voltage", + "AIRLOCK000006": "IRU Current" + }, "EVA_CHARGERS": { "AIRLOCK000011": "BCA 1 Voltage", "AIRLOCK000012": "BCA 1 Current", @@ -76,6 +86,6 @@ "RUSSEG000014": "Aft Docking Port", "RUSSEG000021": "RS Attitude Mode", "RUSSEG000025": "RS Dynamic Mode", - "RUSSEG000011": "SM Propulsion" + "RUSSEG000011": "SM Kurs-P Standby" } } \ No newline at end of file From 5339a1f627e30d742f692077f046379850c4a963 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:40:25 +0000 Subject: [PATCH 59/67] Update iss.py --- iss/iss.py | 65 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 687a166..01ec295 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -4,6 +4,7 @@ import datetime import time import asyncio +import math from pathlib import Path from redbot.core import commands from redbot.core.utils.chat_formatting import box, pagify @@ -21,14 +22,14 @@ def __init__(self, cog): discord.SelectOption(label="Water Systems", value="ETHOS_WATER", description="Urine Tank, Clean Water", emoji="💧"), discord.SelectOption(label="Power Status", value="SPARTAN_POWER", description="Solar Arrays & SARJ", emoji="⚡"), discord.SelectOption(label="Robotics", value="ROBOTICS", description="Canadarm2 Joint Data", emoji="🦾"), - discord.SelectOption(label="EVA / Airlock", value="EVA_SUITS", description="Suit Voltages & Pressure", emoji="👨‍🚀"), + discord.SelectOption(label="EVA Suits", value="EVA_SUITS", description="Suit Voltages & Pressure", emoji="👨‍🚀"), + discord.SelectOption(label="EVA Power", value="EVA_POWER", description="Airlock & IRU Power", emoji="🔋"), discord.SelectOption(label="Russian Segment", value="RUSSIAN", description="Docking & RS Mode", emoji="🇷🇺"), ] super().__init__(placeholder="Select a system to monitor...", options=options) async def callback(self, interaction: discord.Interaction): embed = await self.cog.build_embed([self.values[0]], f"🛰️ {self.values[0]} Telemetry", 0x2b2d31) - # Ephemeral=False ensures the response is public await interaction.response.send_message(embed=embed, ephemeral=False) class SelectionView(discord.ui.View): @@ -73,15 +74,16 @@ def onItemUpdate(self, update): self.outer.last_update = datetime.datetime.fromtimestamp(now, datetime.timezone.utc) self.outer.last_item_update[item] = now - if item not in self.outer.all_ids: - self.outer.discovered_ids.add(item) - if val is None: return try: num = float(val) - if "Voltage" in item: self.outer.data_cache[item] = f"{num:.3f}" - elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.outer.data_cache[item] = f"{num:.2f}" - elif "Mass" in item: self.outer.data_cache[item] = f"{num:,.0f}" + if item in ["USLAB000001", "USLAB000002", "USLAB000003", "USLAB000004"]: + self.outer.data_cache[item] = "Active ✅" if int(num) == 1 else "Standby 💤" + elif "Voltage" in item: self.outer.data_cache[item] = f"{num:.2f} V" + elif "Current" in item: self.outer.data_cache[item] = f"{num:.2f} A" + elif "Angle" in item or item.endswith(("PIT", "YAW", "ROL")): self.outer.data_cache[item] = f"{num:.2f}°" + elif "Mass" in item: self.outer.data_cache[item] = f"{num:,.0f} kg" + elif "ALT" in item: self.outer.data_cache[item] = f"{num:.2f} km" else: self.outer.data_cache[item] = f"{num:,.2f}" except (ValueError, TypeError): self.outer.data_cache[item] = str(val) @@ -90,7 +92,7 @@ def onItemUpdate(self, update): self.ls_client.connect() self.ls_client.subscribe(sub) except Exception as e: - log.error(f"ISS Mimic Connection Failure: {e}") + log.error(f"failed to connect to ISS telemetry: {e}") def cog_unload(self): if self.ls_client: self.ls_client.disconnect() @@ -98,6 +100,17 @@ def cog_unload(self): async def build_embed(self, category_keys: list, title: str, color: int): embed = discord.Embed(title=title, color=color) now = time.time() + + if "GNC" in category_keys: + try: + vx = float(self.data_cache.get("USLAB000035", "0").split()[0].replace(",", "")) + vy = float(self.data_cache.get("USLAB000036", "0").split()[0].replace(",", "")) + vz = float(self.data_cache.get("USLAB000037", "0").split()[0].replace(",", "")) + total_v = math.sqrt(vx**2 + vy**2 + vz**2) + embed.description = f"🚀 **Total Orbital Velocity:** `{total_v:,.2f} m/s`" + except: + embed.description = "🚀 **Total Orbital Velocity:** `Calculating...`" + for key in category_keys: sensors = self.telemetry_map.get(key, {}) lines = [] @@ -106,28 +119,27 @@ async def build_embed(self, category_keys: list, title: str, color: int): val = self.data_cache.get(id_k, "Connecting...") is_active = (now - self.last_item_update.get(id_k, 0)) < 60 if is_active: active_in_cat = True - prefix = "🔹 " if is_active else "" + prefix = "🔹 " if is_active else "🔸 " lines.append(f"{prefix}**{label}:** `{val}`") status_emoji = "🟢" if active_in_cat else "💤" embed.add_field(name=f"{status_emoji} {key.replace('_', ' ')}", value="\n".join(lines) or "No Data", inline=True) if self.last_update: - ts = self.last_update.strftime("%H:%M:%S UTC") - embed.set_footer(text=f"NASA Stream: {ts} | Signal: Acquired 📡") + embed.set_footer(text=f"NASA Live: {self.last_update.strftime('%H:%M:%S UTC')} | Signal: Acquired 📡") return embed @commands.group(invoke_without_command=True) async def iss(self, ctx): """ISS Telemetry Command Hub""" view = SelectionView(self) - await ctx.send("📡 **Interactive ISS Telemetry Console**\nChoose a category below to view live data:", view=view) + await ctx.send("📡 **Mission Control Console**\nSelect a system to view live station telemetry:", view=view) @iss.command(name="all") async def iss_all(self, ctx): """Station Overview (Dual-Module Feed)""" e1 = await self.build_embed(["GNC", "ETHOS_AIR", "ETHOS_WATER"], "🛰️ Primary Systems", 0x2b2d31) - e2 = await self.build_embed(["SPARTAN_POWER", "ROBOTICS", "RUSSIAN"], "🛰️ Engineering & Logistics", 0x2b2d31) + e2 = await self.build_embed(["SPARTAN_POWER", "ROBOTICS", "EVA_POWER", "RUSSIAN"], "🛰️ Engineering & Logistics", 0x2b2d31) await ctx.send(embed=e1) await ctx.send(embed=e2) @@ -149,21 +161,19 @@ async def iss_robotics(self, ctx): embed = await self.build_embed(["ROBOTICS"], "🦾 SSRMS Robotics", 0xe67e22) await ctx.send(embed=embed) + @iss.command(name="eva") + async def iss_eva(self, ctx): + """Extravehicular Activity & Airlock""" + embed = await self.build_embed(["EVA_SUITS", "EVA_POWER"], "👨‍🚀 EVA Operations", 0x9b59b6) + await ctx.send(embed=embed) + @iss.command(name="status") async def iss_status(self, ctx): """Sensor Activity Report""" now = time.time() active = [id_k for id_k in self.all_ids if (now - self.last_item_update.get(id_k, 0)) < 60] - embed = discord.Embed(title="📡 Data Stream Health", color=0x2ecc71 if active else 0xe74c3c) - embed.add_field(name="Summary", value=f"✅ Active: `{len(active)}` | 💤 Standby: `{len(self.all_ids)-len(active)}`", inline=False) - if active: - names = [] - for id_k in active[:15]: - for cat in self.telemetry_map.values(): - if id_k in cat: names.append(f"🟢 {cat[id_k]}") - embed.description = "**Currently Streaming:**\n" + "\n".join(names) - else: - embed.description = "⚠️ No active data. Station may be in LOS (Loss of Signal)." + embed = discord.Embed(title="📡 Stream Health", color=0x2ecc71 if active else 0xe74c3c) + embed.add_field(name="Data Points", value=f"✅ Active: `{len(active)}` | 💤 Standby: `{len(self.all_ids)-len(active)}`", inline=False) await ctx.send(embed=embed) @iss.command(name="reconnect") @@ -179,20 +189,19 @@ async def iss_reconnect(self, ctx): @commands.is_owner() async def iss_scan(self, ctx): """Hunt for untracked NASA Opcodes (Owner Only)""" - await ctx.send("🛰️ **Scanning NASA broad-range telemetry...** (30s scan)") + await ctx.send("🛰️ **Scanning broad-range telemetry...** (30s)") prefixes = ["USLAB", "NODE1", "NODE2", "NODE3", "AIRLOCK", "SSRMS", "S1", "P1"] test_ids = [f"{p}{str(i).zfill(7)}" for p in prefixes for i in range(1, 20)] scan_sub = Subscription(mode="MERGE", items=test_ids, fields=["Value"]) self.ls_client.subscribe(scan_sub) await asyncio.sleep(30) self.ls_client.unsubscribe(scan_sub) - await ctx.send(f"✅ Scan complete. Found `{len(self.discovered_ids)}` new Opcodes. Use `*iss discover` to view.") + await ctx.send(f"✅ Scan complete. Found `{len(self.discovered_ids)}` new Opcodes.") @iss.command(name="discover") @commands.is_owner() async def iss_discover(self, ctx): """Show untracked IDs caught by the scanner (Owner Only)""" - if not self.discovered_ids: - return await ctx.send("❌ No untracked IDs detected. Try running `*iss scan` first.") + if not self.discovered_ids: return await ctx.send("❌ No IDs detected.") pages = [box(p, lang="text") for p in pagify("\n".join(sorted(list(self.discovered_ids))), page_length=1000)] await menu(ctx, pages, DEFAULT_CONTROLS) \ No newline at end of file From affaa45528946f7547fbb35c02a69179e7791c4b Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:45:26 +0000 Subject: [PATCH 60/67] more --- iss/iss.py | 9 +++++++++ iss/telemetry.json | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/iss/iss.py b/iss/iss.py index 01ec295..8a7d401 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -155,6 +155,15 @@ async def iss_ethos(self, ctx): embed = await self.build_embed(["ETHOS_AIR", "ETHOS_WATER"], "🌡️ Life Support Systems", 0x3498db) await ctx.send(embed=embed) + @iss.command(name="comm") + async def iss_comm(self, ctx): + await ctx.send(embed=await self.build_embed(["COMMUNICATIONS"], "📡 Station Comms", 0x607d8b)) + + @iss.command(name="approach") + async def iss_approach(self, ctx): + await ctx.send(embed=await self.build_embed(["RENDEZVOUS"], "🛰️ Rendezvous Monitor", 0xe91e63)) + + @iss.command(name="status") @iss.command(name="robotics") async def iss_robotics(self, ctx): """Canadarm2 Status""" diff --git a/iss/telemetry.json b/iss/telemetry.json index 6a69a77..b532562 100644 --- a/iss/telemetry.json +++ b/iss/telemetry.json @@ -87,5 +87,19 @@ "RUSSEG000021": "RS Attitude Mode", "RUSSEG000025": "RS Dynamic Mode", "RUSSEG000011": "SM Kurs-P Standby" + }, + "COMMUNICATIONS": { + "USLAB000099": "UHF Radio 1 Power", + "USLAB000100": "UHF Radio 2 Power", + "USLAB000101": "UHF Frame Sync Lock", + "Z1000013": "Ku-Band Transmit", + "Z1000014": "Ku-Antenna Elevation", + "Z1000015": "Ku-Antenna Cross-El" + }, + "RENDEZVOUS": { + "USLAB000101": "Vehicle Handshake", + "RUSSEG000013": "Fwd Docking Port", + "RUSSEG000014": "Aft Docking Port", + "RUSSEG000011": "Propulsion Ready" } } \ No newline at end of file From 890e7f7656e419a9444a4b554d4b06cef44d9c78 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:46:18 +0000 Subject: [PATCH 61/67] Update iss.py --- iss/iss.py | 1 - 1 file changed, 1 deletion(-) diff --git a/iss/iss.py b/iss/iss.py index 8a7d401..e032d7f 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -163,7 +163,6 @@ async def iss_comm(self, ctx): async def iss_approach(self, ctx): await ctx.send(embed=await self.build_embed(["RENDEZVOUS"], "🛰️ Rendezvous Monitor", 0xe91e63)) - @iss.command(name="status") @iss.command(name="robotics") async def iss_robotics(self, ctx): """Canadarm2 Status""" From fa9e102c2acfc39dccd9d6256c342b969023389d Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:47:35 +0000 Subject: [PATCH 62/67] Update iss.py --- iss/iss.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/iss/iss.py b/iss/iss.py index e032d7f..d186126 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -25,6 +25,9 @@ def __init__(self, cog): discord.SelectOption(label="EVA Suits", value="EVA_SUITS", description="Suit Voltages & Pressure", emoji="👨‍🚀"), discord.SelectOption(label="EVA Power", value="EVA_POWER", description="Airlock & IRU Power", emoji="🔋"), discord.SelectOption(label="Russian Segment", value="RUSSIAN", description="Docking & RS Mode", emoji="🇷🇺"), += discord.SelectOption(label="Communications", value="COMMUNICATIONS", description="Radios & Antennas", emoji="📡"), + discord.SelectOption(label="Rendezvous", value="RENDEZVOUS", description="Approach Monitor", emoji="🛰️"), + ] super().__init__(placeholder="Select a system to monitor...", options=options) From a094a8326c0ac03e2380bf5118ed5641d4a579b7 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:47:53 +0000 Subject: [PATCH 63/67] fix ident --- iss/iss.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iss/iss.py b/iss/iss.py index d186126..293db2c 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -25,7 +25,7 @@ def __init__(self, cog): discord.SelectOption(label="EVA Suits", value="EVA_SUITS", description="Suit Voltages & Pressure", emoji="👨‍🚀"), discord.SelectOption(label="EVA Power", value="EVA_POWER", description="Airlock & IRU Power", emoji="🔋"), discord.SelectOption(label="Russian Segment", value="RUSSIAN", description="Docking & RS Mode", emoji="🇷🇺"), -= discord.SelectOption(label="Communications", value="COMMUNICATIONS", description="Radios & Antennas", emoji="📡"), += discord.SelectOption(label="Communications", value="COMMUNICATIONS", description="Radios & Antennas", emoji="📡"), discord.SelectOption(label="Rendezvous", value="RENDEZVOUS", description="Approach Monitor", emoji="🛰️"), ] From 56f5b85e2e8c06cba83e39b49542b064a7091991 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:48:50 +0000 Subject: [PATCH 64/67] typo --- iss/iss.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iss/iss.py b/iss/iss.py index 293db2c..2e59eb1 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -25,7 +25,7 @@ def __init__(self, cog): discord.SelectOption(label="EVA Suits", value="EVA_SUITS", description="Suit Voltages & Pressure", emoji="👨‍🚀"), discord.SelectOption(label="EVA Power", value="EVA_POWER", description="Airlock & IRU Power", emoji="🔋"), discord.SelectOption(label="Russian Segment", value="RUSSIAN", description="Docking & RS Mode", emoji="🇷🇺"), -= discord.SelectOption(label="Communications", value="COMMUNICATIONS", description="Radios & Antennas", emoji="📡"), + discord.SelectOption(label="Communications", value="COMMUNICATIONS", description="Radios & Antennas", emoji="📡"), discord.SelectOption(label="Rendezvous", value="RENDEZVOUS", description="Approach Monitor", emoji="🛰️"), ] From 21c901239b124019f1cbfbaf34f16bc7b15a8da0 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:59:14 +0000 Subject: [PATCH 65/67] make iss all work better --- iss/iss.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 2e59eb1..81c8d87 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -138,14 +138,23 @@ async def iss(self, ctx): view = SelectionView(self) await ctx.send("📡 **Mission Control Console**\nSelect a system to view live station telemetry:", view=view) + @iss.command(name="all") async def iss_all(self, ctx): - """Station Overview (Dual-Module Feed)""" - e1 = await self.build_embed(["GNC", "ETHOS_AIR", "ETHOS_WATER"], "🛰️ Primary Systems", 0x2b2d31) - e2 = await self.build_embed(["SPARTAN_POWER", "ROBOTICS", "EVA_POWER", "RUSSIAN"], "🛰️ Engineering & Logistics", 0x2b2d31) + """Station Overview (Dynamic Full-Suite Feed)""" + # Get all category names from your JSON file automatically + all_categories = list(self.telemetry_map.keys()) + + # Split them into two groups so the embeds aren't too long for Discord + halfway = len(all_categories) // 2 + group1 = all_categories[:halfway] + group2 = all_categories[halfway:] + + e1 = await self.build_embed(group1, "🛰️ Station Systems: Alpha", 0x2b2d31) + e2 = await self.build_embed(group2, "🛰️ Station Systems: Bravo", 0x2b2d31) + await ctx.send(embed=e1) await ctx.send(embed=e2) - @iss.command(name="gnc") async def iss_gnc(self, ctx): """Guidance, Navigation, and Control""" From 87f3ca26f24b246a61808cee03711fed2f6b2ab0 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:01:37 +0000 Subject: [PATCH 66/67] Update iss.py --- iss/iss.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 81c8d87..5676e7e 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -58,7 +58,7 @@ def __init__(self, bot): for category in self.telemetry_map.values(): self.all_ids.extend(category.keys()) - self.data_cache = {k: "Connecting..." for k in self.all_ids} + self.data_cache = {k: "No Data 📶" for k in self.all_ids} self.start_ls_client() def start_ls_client(self): @@ -138,7 +138,7 @@ async def iss(self, ctx): view = SelectionView(self) await ctx.send("📡 **Mission Control Console**\nSelect a system to view live station telemetry:", view=view) - + @iss.command(name="all") async def iss_all(self, ctx): """Station Overview (Dynamic Full-Suite Feed)""" From dfb6598e2e3f6e184f2163aebafff06771d3e1c3 Mon Sep 17 00:00:00 2001 From: Ben Cos <52817096+BenCos17@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:08:26 +0000 Subject: [PATCH 67/67] import code notes for future reference --- iss/iss.py | 56 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/iss/iss.py b/iss/iss.py index 5676e7e..77500f8 100644 --- a/iss/iss.py +++ b/iss/iss.py @@ -1,19 +1,20 @@ -import discord -import json -import logging -import datetime -import time -import asyncio -import math -from pathlib import Path -from redbot.core import commands -from redbot.core.utils.chat_formatting import box, pagify -from redbot.core.utils.menus import menu, DEFAULT_CONTROLS -from lightstreamer.client import LightstreamerClient, Subscription - -log = logging.getLogger("red.iss") - -class CategorySelect(discord.ui.Select): +import discord # discord.py import +import json # For loading the telemetry mapping from a JSON file +import logging # For logging connection status and errors with the telemetry feed +import datetime # For handling timestamps and displaying last update times in the embed footers +import time # For timestamps and calculating data freshness +import asyncio # For async sleep in the scan command +import math # For velocity calculations +from pathlib import Path # For loading the telemetry mapping from a JSON file +from redbot.core import commands +from redbot.core.utils.chat_formatting import box, pagify +from redbot.core.utils.menus import menu, DEFAULT_CONTROLS +from lightstreamer.client import LightstreamerClient, Subscription # pip install lightstreamer-client + +log = logging.getLogger("red.iss") + + +class CategorySelect(discord.ui.Select): def __init__(self, cog): self.cog = cog options = [ @@ -138,23 +139,25 @@ async def iss(self, ctx): view = SelectionView(self) await ctx.send("📡 **Mission Control Console**\nSelect a system to view live station telemetry:", view=view) - +# The main command `!iss all` provides a comprehensive overview of all telemetry categories in a dynamic, full-suite feed. It automatically retrieves all category names from the JSON mapping and splits them into two groups to create multiple embeds to not hit the discord embed limits. @iss.command(name="all") - async def iss_all(self, ctx): + async def iss_all(self, ctx): """Station Overview (Dynamic Full-Suite Feed)""" - # Get all category names from your JSON file automatically + # Get all category names from JSON file all_categories = list(self.telemetry_map.keys()) # Split them into two groups so the embeds aren't too long for Discord halfway = len(all_categories) // 2 group1 = all_categories[:halfway] - group2 = all_categories[halfway:] + group2 = all_categories[halfway:] # aAllows new categories added to the JSON file to be automatically included in the "all" command without needing to hardcode them here. - e1 = await self.build_embed(group1, "🛰️ Station Systems: Alpha", 0x2b2d31) + e1 = await self.build_embed(group1, "🛰️ Station Systems: Alpha", 0x2b2d31) e2 = await self.build_embed(group2, "🛰️ Station Systems: Bravo", 0x2b2d31) - await ctx.send(embed=e1) - await ctx.send(embed=e2) + await ctx.send(embed=e1) + await ctx.send(embed=e2) + +# Individual system commands for direct access, these are also accessible via the dropdown menu in the main command. Each one corresponds to a category in the telemetry.json file and will display the relevant sensors with their latest values and status indicators. @iss.command(name="gnc") async def iss_gnc(self, ctx): """Guidance, Navigation, and Control""" @@ -196,6 +199,12 @@ async def iss_status(self, ctx): embed.add_field(name="Data Points", value=f"✅ Active: `{len(active)}` | 💤 Standby: `{len(self.all_ids)-len(active)}`", inline=False) await ctx.send(embed=embed) + + + + + +# owner only commands for maintenance and discovery of new telemetry IDs (untested fully but should work) - these are not meant for regular users or even bot owners and may cause spam if misused, so they are hidden from the help command and restricted to the bot owner. @iss.command(name="reconnect") @commands.is_owner() async def iss_reconnect(self, ctx): @@ -218,6 +227,7 @@ async def iss_scan(self, ctx): self.ls_client.unsubscribe(scan_sub) await ctx.send(f"✅ Scan complete. Found `{len(self.discovered_ids)}` new Opcodes.") + @iss.command(name="discover") @commands.is_owner() async def iss_discover(self, ctx):