Skip to content

Commit

Permalink
Merge pull request #18 from ezod/bluesky
Browse files Browse the repository at this point in the history
Bluesky Conversion
  • Loading branch information
ezod authored Dec 20, 2024
2 parents 3b5caf5 + ca3cb5c commit 03e2da0
Show file tree
Hide file tree
Showing 8 changed files with 107 additions and 93 deletions.
30 changes: 6 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
`aistweet`, a Twitter photo bot for Raspberry Pi AIS tracking stations
`aistweet`, a Bluesky photo bot for Raspberry Pi AIS tracking stations
----------------------------------------------------------------------

aistweet tracks ships via AIS and takes their picture with a Raspberry Pi
Expand Down Expand Up @@ -35,7 +35,7 @@ usage: aistweet.py [-h] [--host HOST] [--port PORT] [--db DB]
[--hashtags HASHTAGS [HASHTAGS ...]] [--tts]
latitude longitude
Raspberry Pi AIS tracker/camera Twitter bot
Raspberry Pi AIS tracker/camera Bluesky bot
positional arguments:
latitude AIS station latitude
Expand All @@ -47,34 +47,18 @@ optional arguments:
--host HOST host for receiving UDP AIS messages
--port PORT port for receiving UDP AIS messages
--db DB database file for static ship data
--hashtags HASHTAGS [HASHTAGS ...]
hashtags to add to tweets
--tts announce ship name via text-to-speech
--light disable night snapshots via light sensor
required environment variables:
TWITTER_CONSUMER_API_KEY
TWITTER_CONSUMER_API_KEY_SECRET
TWITTER_ACCESS_TOKEN
TWITTER_ACCESS_TOKEN_SECRET
TWITTER_CLIENT_ID
TWITTER_CLIENT_SECRET
TWITTER_CALLBACK_URI
BLUESKY_USERNAME
BLUESKY_PASSWORD
```

How To Authenticate With Twitter
--------------------------------

Follow the instructions on the [tweeter-basic] repository to:
- create a Twitter v2 API project
- generate the required keys to be set as environment variables
- note: the callback URI is recommended to be http://localhost/callback
- generate the required token by doing a 1-time self-hosted browser authentication
- note: the token is auto-refreshed with every tweet. However, if the token isn't refreshed in 6 months it will require user intervention to be re-generated.

Dependencies
------------
- [astral](https://pypi.org/project/astral/)
- [atproto](https://pypi.org/project/atproto/)
- [emoji-country-flag](https://pypi.org/project/emoji-country-flag/)
- [event-scheduler](https://pypi.org/project/event-scheduler/)
- [geopy](https://pypi.org/project/geopy/)
Expand All @@ -83,17 +67,15 @@ Dependencies
- [pyais](https://pypi.org/project/pyais/)
- [pytz](https://pypi.org/project/pytz/)
- [timezonefinder](https://pypi.org/project/timezonefinder/)
- [tweeter-basic]
- [adafruit-circuitpython-veml7700](https://pypi.org/project/adafruit-circuitpython-veml7700/) (optional)
- [gTTS](https://pypi.org/project/gTTS/) (optional)

[Detroit River Boat Tracker]: https://twitter.com/detroitships
[Detroit River Boat Tracker]: https://bsky.app/profile/detroitriverboats.bsky.social
[AIS]: https://en.wikipedia.org/wiki/Automatic_identification_system
[Nooelec NESDR Smart v4]: https://www.nooelec.com/store/sdr/sdr-receivers/nesdr-smart-sdr.html
[Raspberry Pi]: https://www.raspberrypi.org/
[Raspberry Pi Camera Module]: https://www.raspberrypi.org/products/camera-module-v2/
[Adafruit VEML7700]: http://learn.adafruit.com/adafruit-veml7700
[AIS Dispatcher for Linux]: https://www.aishub.net/ais-dispatcher?tab=linux
[rtl-ais]: https://github.com/dgiardini/rtl-ais
[tweeter-basic]: https://github.com/MikeBusuttil/tweeter-basic
[how it was made]: https://www.prosiglieres.com/posts/detroit-river-boat-tracker-project/
10 changes: 1 addition & 9 deletions aistweet.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Raspberry Pi AIS tracker/camera Twitter bot"
description="Raspberry Pi AIS tracker/camera Bluesky bot"
)
parser.add_argument("latitude", type=float, help=("AIS station latitude"))
parser.add_argument("longitude", type=float, help=("AIS station longitude"))
Expand All @@ -28,13 +28,6 @@
"--port", type=int, default=10110, help=("port for receiving UDP AIS messages")
)
parser.add_argument("--db", type=str, help=("database file for static ship data"))
parser.add_argument(
"--hashtags",
type=str,
nargs="+",
default=[],
help=("hashtags to add to tweets"),
)
parser.add_argument(
"--tts", action="store_true", help=("announce ship name via text-to-speech")
)
Expand All @@ -52,7 +45,6 @@
tweeter = Tweeter(
tracker,
args.direction,
args.hashtags,
args.tts,
args.light,
)
Expand Down
18 changes: 18 additions & 0 deletions aistweet/compress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import os
from PIL import Image


def resize_and_compress(input_path, output_path, max_size, target_resolution):
with Image.open(input_path) as image:
# resize to target resolution
if image.size[0] > target_resolution[0] or image.size[1] > target_resolution[1]:
image = image.resize(target_resolution, Image.LANCZOS)

# reduce quality until below maximum size
quality = 95
while quality > 10:
image.save(output_path, format="JPEG", quality=quality)
if os.path.getsize(output_path) <= max_size:
return True
quality -= 5
return False
5 changes: 3 additions & 2 deletions aistweet/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def crossing_time_and_depth(
camera_heading: float,
vessel_lat: float,
vessel_lon: float,
vessel_speed: float,
vessel_course: float,
t: float,
) -> Tuple[float, float]:
Expand Down Expand Up @@ -98,8 +99,8 @@ def crossing_time_and_depth(
int_lon = math.degrees(int_lon_r)

d = distance((vessel_lat, vessel_lon), (int_lat, int_lon)).m
depth = distance((self.lat, self.lon), (int_lat, int_lon)).m
depth = distance((camera_lat, camera_lon), (int_lat, int_lon)).m
except ValueError:
return None, None

return self.ships[mmsi]["last_update"] + d / kn_to_m_s(speed), depth
return t + d / kn_to_m_s(vessel_speed), depth
3 changes: 2 additions & 1 deletion aistweet/ship_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def __init__(self, host, port, latitude, longitude, db_file=None):
@staticmethod
def readcsv(filename):
d = {}
path = resource_filename("aistweet", "data/{}.csv".format(filename))
path = resource_filename("aistweet", f"data/{filename}.csv")
with open(path, newline="") as f:
reader = csv.reader(f)
for row in reader:
Expand Down Expand Up @@ -214,6 +214,7 @@ def crossing(self, mmsi, direction):
direction,
ship_lat,
ship_lon,
self.ships[mmsi]["speed"],
self.ships[mmsi]["course"],
self.ships[mmsi]["last_update"],
)
Expand Down
129 changes: 74 additions & 55 deletions aistweet/tweeter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import threading
import time

from Tweet import Tweet
from atproto import Client, models
from event_scheduler import EventScheduler

import astral
Expand All @@ -29,21 +29,26 @@
except ModuleNotFoundError:
adafruit_veml7700 = None

from aistweet.compress import resize_and_compress


class Tweeter(object):
CAMERA_WARMUP = 1.0
CAMERA_DELAY = 1.0
LIGHT_LEVEL_MAX = 50

def __init__(
self, tracker, direction, hashtags=[], tts=False, light=False, logging=True
self,
tracker,
direction,
tts=False,
light=False,
logging=True,
):
self.tracker = tracker

self.direction = direction

self.hashtags = hashtags

self.tts = tts if gtts is not None else False

self.logging = logging
Expand Down Expand Up @@ -72,13 +77,6 @@ def __init__(
i2c = busio.I2C(board.SCL, board.SDA)
self.light_sensor = adafruit_veml7700.VEML7700(i2c)

# set up Twitter connection
self.twitter = Tweet(
client_id=environ["TWITTER_CLIENT_ID"],
client_secret=environ["TWITTER_CLIENT_SECRET"],
callback_uri=environ["TWITTER_CALLBACK_URI"],
)

self.scheduler.start()

# register callback
Expand All @@ -89,8 +87,7 @@ def stop(self):

def log(self, mmsi, message):
if self.logging:
shipname = self.tracker[mmsi]["shipname"]
print("[{}] {}: {}".format(str(datetime.datetime.now()), shipname, message))
print(f"[{datetime.datetime.now()}] {self.shipname(mmsi)}: {message}")

def check(self, mmsi, t):
crossing, depth = self.tracker.crossing(mmsi, self.direction)
Expand All @@ -106,7 +103,7 @@ def check(self, mmsi, t):
self.schedule[mmsi] = self.scheduler.enter(
delta, 1, self.snap_and_tweet, arguments=(mmsi, depth)
)
self.log(mmsi, "scheduled for tweet in {} seconds".format(delta))
self.log(mmsi, f"scheduled for tweet in {delta} seconds")

def purge_schedule(self, mmsi):
try:
Expand All @@ -128,23 +125,56 @@ def snap_and_tweet(self, mmsi, depth):
large = self.tracker.dimensions(mmsi)[0] > (0.542915 * depth)

# grab the image
image_path = os.path.join("/tmp", "{}.jpg".format(mmsi))
image_path = os.path.join("/tmp", f"{mmsi}.jpg")
if not self.snap(image_path, large):
self.log(mmsi, "image capture aborted")
return
self.log(mmsi, "image captured to {}".format(image_path))
resize_and_compress(image_path, image_path, 1000000, (1640, 1232))
self.log(mmsi, f"image captured to {image_path}")

# set up Bluesky connection
username = os.getenv("BLUESKY_USERNAME")
password = os.getenv("BLUESKY_PASSWORD")
client = Client("https://bsky.social")
client.login(username, password)

# tweet the image with info
lat, lon = self.tracker.center_coords(mmsi)
# create post
try:
self.twitter.tweet(
text=self.generate_text(mmsi),
image_path=image_path,
lat=lat,
long=lon,
shipname = self.shipname(mmsi)

with open(image_path, "rb") as image_file:
upload = client.upload_blob(image_file)
images = [
models.AppBskyEmbedImages.Image(alt=shipname, image=upload.blob)
]
embed = models.AppBskyEmbedImages.Main(images=images)

text = self.generate_text(mmsi)

url = f"https://www.marinetraffic.com/en/ais/details/ships/mmsi:{mmsi}"
facets = [
{
"index": {"byteStart": 9, "byteEnd": 9 + len(shipname)},
"features": [
{"$type": "app.bsky.richtext.facet#link", "uri": url}
],
}
]

client.com.atproto.repo.create_record(
models.ComAtprotoRepoCreateRecord.Data(
repo=client.me.did,
collection=models.ids.AppBskyFeedPost,
record=models.AppBskyFeedPost.Record(
created_at=client.get_current_time_iso(),
text=text,
embed=embed,
facets=facets,
),
)
)
except Exception as e:
self.log(mmsi, "tweet error: {}".format(e))
self.log(mmsi, f"post error: {e}")

# clean up the image
os.remove(image_path)
Expand All @@ -154,16 +184,16 @@ def snap_and_tweet(self, mmsi, depth):

# announce the ship using TTS
if self.tts:
shipname = self.tracker[mmsi]["shipname"]
if shipname:
speech_path = os.path.join("/tmp", "{}.mp3".format(mmsi))
try:
speech = gtts.gTTS(text=shipname.title(), lang="en", slow=False)
speech.save(speech_path)
os.system("mpg321 -q {}".format(speech_path))
os.remove(speech_path)
except gtts.tts.gTTSError:
pass
speech_path = os.path.join("/tmp", f"{mmsi}.mp3")
try:
speech = gtts.gTTS(
text=self.shipname(mmsi).title(), lang="en", slow=False
)
speech.save(speech_path)
os.system(f"mpg321 -q {speech_path}")
os.remove(speech_path)
except gtts.tts.gTTSError:
pass

self.log(mmsi, "done tweeting")

Expand Down Expand Up @@ -205,47 +235,36 @@ def now(self):
pytz.timezone(self.location.timezone)
)

def shipname(self, mmsi):
return self.tracker[mmsi]["shipname"] or "(Unidentified)"

def generate_text(self, mmsi):
text = ""

flag = self.tracker.flag(mmsi)
if flag:
text += "{} ".format(flag)
text += f"{flag} "

ship = self.tracker[mmsi]

shipname = ship["shipname"]
if shipname:
text += shipname
else:
text += "(Unidentified)"

text += ", {}".format(self.tracker.ship_type(mmsi))
text += self.shipname(mmsi)
text += f", {self.tracker.ship_type(mmsi)}"

length, width = self.tracker.dimensions(mmsi)
if length > 0 and width > 0:
text += " ({l} x {w} m)".format(l=length, w=width)
text += f" ({length} x {width} m)"

status = self.tracker.status(mmsi)
if status is not None:
text += ", {}".format(status)
text += f", {status}"

destination = ship["destination"]
if destination:
text += ", destination: {}".format(destination)
text += f", destination: {destination}"

course = ship["course"]
speed = ship["speed"]
if course is not None and speed is not None:
text += ", course: {c:.1f} \N{DEGREE SIGN} / speed: {s:.1f} kn".format(
c=course, s=speed
)

text += " https://www.marinetraffic.com/en/ais/details/ships/mmsi:{}".format(
mmsi
)

for hashtag in self.hashtags:
text += " #{}".format(hashtag)
text += f", course: {course:.1f} \N{DEGREE SIGN} / speed: {speed:.1f} kn"

return text
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "aistweet"
authors = [
{name = "Aaron Mavrinac", email = "[email protected]"},
]
description = "Twitter photo bot for Raspberry Pi AIS tracking stations"
description = "Bluesky photo bot for Raspberry Pi AIS tracking stations"
readme = "README.md"
requires-python = ">=3.7"
classifiers = [
Expand Down
Loading

0 comments on commit 03e2da0

Please sign in to comment.