40
submitted 1 week ago* (last edited 1 week ago) by estranho@lemmy.ca to c/homelab@lemmy.ml

A few weeks ago I heard about n8n and I was looking for a way to use it, and I figured the best way was to create a challenge for myself and see if I could accomplish it. I have a Proxmox server running a few Linux VMs, an M1 Macbook Pro that I tinker with AI and random other things with, and a Gemini Pro account that came free with my phone. I had also been out the night before and just randomly saw the ISS flyover, so I figured I should try creating a tracker that will alert me whenever the ISS will be visible from my home.

When it comes to 'DevOps' I'm much more 'Ops' than 'Dev', but Gemini helps out a lot there (although, you still have to somewhat know what you're doing or it'll drive you mad with its stupid mistakes).

I first had it make the python script to determine when the ISS will be visible from my zip code over a period of 24 hours. It gathers the following information:

  • time it will become visible,
  • the direction in the sky it will appear,
  • how high it will get,
  • how long it will be visible,
  • the time it will disappear, and
  • the direction in the sky it will disappear.

This script sits on a Linux VM on my network, which is also connected to my Tailnet.

From here I moved over to my locally hosted n8n server where it is set to run every morning at 8am. It then connects to the Linux VM via SSH to run the Python script and then receives the relevant output data as JSON. Then n8n parses the data and then a summary message is sent to me immediately via Signal, and reminder messages are scheduled to be sent 15 minutes prior to any flyovers for the day.

I'm using Signal-cli to send the messages, and it has been working great! I wouldn't want to use Signal-cli manually, but it's great for when you want to programmatically send messages with Signal. I just wish there was a way to create 'Stories' using Signal-cli... but it doesn't look like that functionality is ready just yet.

To make things a bit more interesting I wanted to add a unique image to the summary message for days when there's a flyover. I decided to use Gemini for the prompt generation, just because I have access to it and wanted to see how difficult it would be (not difficult at all). I gave Gemini very broad instructions, something like "It has to be about the ISS, be creative, it needs to be less than 75 tokens, and provide the prompt in JSON format". I then send that prompt over to my locally hosted Automatic1111 instance and it returns an image. I pair that image with the summary message and send it out.

I worked on this off and on in my limited spare time for about 3 days, and found it extremely powerful and easy to accomplish. The parsing of the data is done with Python, and I'm able to access my other computers via SSH and remotely run commands, and the tools for interacting with various AI tools makes n8n very attractive to me for future projects. I'm looking at converting my smart home over to Home Assistant in the very near future, and I have a feeling that n8n is going to allow me to do some very incredible things.

Here's what my completed n8n workflow looks like:

This was a very fun project and I can't wait to get started on my next one!

I wanted to edit this to include that the data in the image was test data... since there were no passes for the next few weeks I had to tweak it a bit in order to get info to show up. That's why it says '1 pass in the next 24 hours' but then lists the pass as being over 2 weeks out.

top 3 comments
sorted by: hot top controversial new old
[-] rozlav 1 points 1 week ago

awesome, I have this exact same project in my basket but never had time, any chance sharing python scripts here or on a git repo ?

[-] estranho@lemmy.ca 3 points 6 days ago

I don't have this setup on github yet (and likely won't since it was just a learning exercise), but here's the code for tracking when the ISS will be visible:

`

    # USAGE: python3 get_iss_pass.py <zip_code> [days_to_search]
    # This version provides all necessary data with explicit keys for n8n.
 
import sys
import os
import pandas
import pgeocode
from skyfield.api import load, Topos, Loader
from timezonefinder import TimezoneFinder
from datetime import timezone, timedelta
from zoneinfo import ZoneInfo

def get_compass_direction(degrees):
 # This function converts an azimuth angle in degrees into a cardinal or intercardinal compass direction.
 # It divides the 360-degree circle into 16 segments and maps the given degrees to the corresponding direction.
 directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
               "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
 index = int((degrees + 11.25) / 22.5) % 16
 return directions[index]

def get_iss_pass_details(zip_code, search_days=3):
 # Initialize pgeocode for US postal code lookups.
 geo = pgeocode.Nominatim('us')
 # Query the provided zip code to get geographical information.
 location_info = geo.query_postal_code(zip_code)

# Check if the location information was successfully retrieved.
if pandas.isna(location_info.latitude):
    print(f"Error: Could not find location for zip code {zip_code}")
    return

# Extract latitude and longitude from the location information.
lat, lon = location_info.latitude, location_info.longitude
# Construct a human-readable place name.
place_name = f"{location_info.place_name}, {location_info.state_code}"

# Initialize TimezoneFinder to determine the timezone for the given coordinates.
tf = TimezoneFinder()
# Get the timezone string; default to "UTC" if not found.
timezone_str = tf.timezone_at(lng=lon, lat=lat) or "UTC"
# Create a ZoneInfo object for the local timezone.
local_tz = ZoneInfo(timezone_str)

# Print the calculated location and timezone information.
print(f"Calculating for {place_name} (Lat: {lat:.2f}, Lon: {lon:.2f})")
print(f"Using timezone: {timezone_str}\n")

try:
    # Define the path for Skyfield data files.
    data_path = os.path.expanduser('~/skyfield-data')
    # Initialize the Skyfield data loader, specifying the data path.
    load = Loader(data_path)
    # Get the Skyfield timescale object, which handles time conversions.
    ts = load.timescale()
    # Load the ephemeris data (positions of celestial bodies) for accurate calculations.
    eph = load('de421.bsp')
    # Define the URL to download the latest Two-Line Elements (TLE) for the ISS.
    stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle'
    # Load the TLE file for satellites, reloading to ensure up-to-date data.
    satellites = load.tle_file(stations_url, reload=True)
except Exception as e:
    # Handle any errors during data download or loading.
    print(f"Could not download astronomical data: {e}")
    return

# Select the International Space Station (ISS), which is typically the first satellite in the 'stations' group.
iss = satellites[0]
# Get the Sun's ephemeris data.
sun = eph['sun']
# Confirm that ISS data has been loaded.
print(f"Loaded satellite data for: {iss.name}")

# Define the observer's location on Earth using Topos.
earth_observer = Topos(latitude_degrees=lat, longitude_degrees=lon)
# Define the observer's position relative to the Solar System Barycenter (SSB).
ssb_observer = eph['earth'] + earth_observer

# Define the start and end times for the ISS pass search.
# t0 is the current time, t1 is the current time plus the specified search_days.
t0 = ts.now()
t1 = t0 + search_days

# Find all events (starts, culminations, ends) where the ISS's altitude is above 10 degrees.
times, events = iss.find_events(earth_observer, t0, t1, altitude_degrees=10.0)
# Initialize a list to store details of visible ISS passes.
found_passes = []

# Iterate through the found events.
for ti, event in zip(times, events):
    # Check if the event is an "appears" event (event code 0).
    if event == 0:
        # Determine if the observer is in darkness (Sun's altitude below -6 degrees, astronomical twilight).
        observer_is_in_dark = (sun - ssb_observer).at(ti).altaz()[0].degrees < -6.0
        # Determine if the ISS is sunlit at this time.
        iss_is_in_sunlit = iss.at(ti).is_sunlit(eph)
        
        # A pass is visible if the observer is in darkness AND the ISS is sunlit.
        if observer_is_in_dark and iss_is_in_sunlit:
            start_time = ti
            culmination_details = None
            end_time = None
            
            # Search for subsequent events (culmination and disappearance) within the pass.
            future_times, future_events = iss.find_events(earth_observer, ti, t1, altitude_degrees=10.0)

            for next_ti, next_event in zip(future_times, future_events):
                # If the event is a "culmination" (event code 1), get its maximum elevation.
                if next_event == 1:
                    alt, _, _ = (iss - earth_observer).at(next_ti).altaz()
                    culmination_details = f"Max Elevation: {int(alt.degrees)}°"
                # If the event is a "disappears" (event code 2), mark it as the end of the pass and break the loop.
                elif next_event == 2:
                    end_time = next_ti
                    break

            # If a valid end time was found for the pass.
            if end_time is not None:
                # Convert start and end times to the local timezone.
                appears_dt_local = start_time.utc_datetime().astimezone(local_tz)
                disappears_dt_local = end_time.utc_datetime().astimezone(local_tz)
                # Calculate a notification time (15 minutes before appearance).
                notification_dt_local = appears_dt_local - timedelta(minutes=15)

                # Get altitude and azimuth for the appearance and disappearance points.
                alt_start, az_start, _ = (iss - earth_observer).at(start_time).altaz()
                alt_end, az_end, _ = (iss - earth_observer).at(end_time).altaz()

                # Format the pass information for output, including explicit keys for n8n.
                pass_info = (
                    f"DURATION: {(end_time - start_time) * 24 * 60:.1f} minutes\n"
                    f"APPEARS_TIME: {appears_dt_local.strftime('%Y-%m-%d %I:%M:%S %p %Z')}\n"
                    f"APPEARS_DIRECTION: {int(az_start.degrees)}° ({get_compass_direction(az_start.degrees)})\n"
                    f"MAX_ELEVATION: {int(alt_start.degrees)}°\n"
                    f"CULMINATION: {culmination_details if culmination_details else 'N/A'}\n"
                    f"DISAPPEARS_TIME: {disappears_dt_local.strftime('%Y-%m-%d %I:%M:%S %p %Z')}\n"
                    f"DISAPPEARS_DIRECTION: {int(az_end.degrees)}° ({get_compass_direction(az_end.degrees)})\n"
                    f"NOTIFICATION_ISO: {notification_dt_local.isoformat()}"
                )
                # Add the formatted pass information to the list of found passes.
                found_passes.append(pass_info)

# Get the total number of visible passes found.
num_passes = len(found_passes)
# Calculate the total search duration in hours.
search_hours = search_days * 24

# Print a summary of the found passes or a message if none were found.
if num_passes > 0:
    print(f"\n--- Found {num_passes} visible pass(es) in the next {search_hours} hours ---")
    for i, pass_detail in enumerate(found_passes, 1):
        print(f"\n--- Pass {i} of {num_passes} ---")
        print(pass_detail)
else:
    print(f"\nNo visible ISS passes found in the next {search_hours} hours for this location.")

# This block ensures the script runs only when executed directly (not imported as a module).
if __name__ == "__main__":
# Check if the correct number of command-line arguments is provided.
if len(sys.argv) < 2:
    print("Usage: python3 get_iss_pass.py <zip_code> [days_to_search]")
    sys.exit(0)

# Get the zip code from the first command-line argument.
zip_code_arg = sys.argv[1]
# Set a default search duration of 3 days.
days_arg = 3

# If a second argument is provided, try to interpret it as the search duration in days.
if len(sys.argv) > 2:
    try:
        days_arg = int(sys.argv[2])
    except ValueError:
        # Handle the error if 'days_to_search' is not a valid integer.
        print("Error: 'days_to_search' must be an integer.")
        sys.exit(1)

# Call the main function to get and display ISS pass details.
get_iss_pass_details(zip_code_arg, days_arg) `

Hope this helps!

[-] rozlav 2 points 6 days ago

woaahhh, thank you so much ! Amazing !!! I'll try it out someday then post it here <3

this post was submitted on 01 Aug 2025
40 points (100.0% liked)

homelab

8493 readers
2 users here now

founded 5 years ago
MODERATORS