Skip to content

Commit 5dc24ad

Browse files
committed
adding rpi bluesky tft scroller
1 parent 46621d0 commit 5dc24ad

File tree

5 files changed

+344
-0
lines changed

5 files changed

+344
-0
lines changed

Pi_Bluesky_TFT_Scroller/.data/.gitkeep

Whitespace-only changes.

Pi_Bluesky_TFT_Scroller/code.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# SPDX-FileCopyrightText: 2024 Tim Cocks
2+
#
3+
# SPDX-License-Identifier: MIT
4+
"""
5+
Bluesky_RPi_TFT_Scroller code.py
6+
Infinitely scroll Bluesky posts on a 320x240 pixel TFT
7+
"""
8+
import json
9+
import os
10+
11+
import requests
12+
import webview
13+
14+
FEEDLINK_RETROCOMPUTING = "https://bsky.app/profile/did:plc:tbo4hkau3p2itkar2vsnb3gp/feed/aaabo5oe7bzok"
15+
16+
# Un-comment a single key inside of FEED_ARGS and set it's value to the feed, list or search
17+
# that you want to scroll.
18+
FETCH_ARGS = {
19+
# "feed_share_link": FEEDLINK_RETROCOMPUTING,
20+
# "feed_share_link": "https://bsky.app/profile/did:plc:463touruejpokvutnn5ikxb5/lists/3lbfdtahfzt2a",
21+
# "search_args": {"q": "Adafruit", "sort": "latest"}
22+
"search_args": {"q": "#circuitpython", "sort": "latest"}
23+
}
24+
25+
26+
def at_feed_uri_from_share_link(share_link):
27+
"""
28+
Converts a share link into an AT URI for that resource.
29+
30+
:param share_link: The share link to convert.
31+
:return str: The AT URI pointing at the resource.
32+
"""
33+
at_feed_uri = share_link.replace("https://bsky.app/profile/", "at://")
34+
if "/feed/" in share_link:
35+
at_feed_uri = at_feed_uri.replace("/feed/", "/app.bsky.feed.generator/")
36+
if "/lists/" in share_link:
37+
at_feed_uri = at_feed_uri.replace("/lists/", "/app.bsky.graph.list/")
38+
return at_feed_uri
39+
40+
41+
def fetch_data(feed_share_link=None, search_args=None):
42+
"""
43+
Fetch posts from Bluesky API and write them into the local cached
44+
data files. After posts are written locally iterates over them
45+
and downloads the relevant photos from them.
46+
47+
Must pass either feed_share_link or search_args.
48+
49+
:param feed_share_link: The link copied from Bluesky front end to share the feed or list.
50+
:param search_args: A dictionary containing at minimum a ``q`` key with string value of
51+
the hashtag or term to search for. See bsky API docs for other supported keys.
52+
:return: None
53+
"""
54+
if feed_share_link is None and search_args is None:
55+
# If both inputs are None, just use retrocomputing feed.
56+
feed_share_link = FEEDLINK_RETROCOMPUTING
57+
58+
# if a share link input was provided
59+
if feed_share_link is not None:
60+
FEED_AT = at_feed_uri_from_share_link(feed_share_link)
61+
# print(FEED_AT)
62+
63+
# if it's a feed
64+
if "/app.bsky.feed.generator/" in FEED_AT:
65+
URL = f"https://public.api.bsky.app/xrpc/app.bsky.feed.getFeed?feed={FEED_AT}&limit=30"
66+
headers = {"Accept-Language": "en"}
67+
resp = requests.get(URL, headers=headers)
68+
69+
# if it's a list
70+
elif "/app.bsky.graph.list/" in FEED_AT:
71+
URL = f"https://public.api.bsky.app/xrpc/app.bsky.feed.getListFeed?list={FEED_AT}&limit=30"
72+
headers = {"Accept-Language": "en"}
73+
resp = requests.get(URL, headers=headers)
74+
75+
# raise error if it's an unknown type
76+
else:
77+
raise ValueError("Only 'app.bsky.feed.generator' and 'app.bsky.graph.list' URIs are supported.")
78+
79+
# if a search input was provided
80+
if search_args is not None:
81+
URL = "https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts"
82+
headers = {"Accept-Language": "en"}
83+
resp = requests.get(URL, headers=headers, params=search_args)
84+
85+
with open(".data/raw_data.json", "wb") as f:
86+
# write raw response to cache
87+
f.write(resp.content)
88+
89+
# Process the post data into a smaller subset
90+
# containing just the bits we need for showing
91+
# on the TFT.
92+
resp_json = json.loads(resp.text)
93+
processed_posts = {"posts": []}
94+
fetched_posts = None
95+
if "feed" in resp_json.keys():
96+
fetched_posts = resp_json["feed"]
97+
elif "posts" in resp_json.keys():
98+
fetched_posts = resp_json["posts"]
99+
100+
for post in fetched_posts:
101+
cur_post = {}
102+
if "post" in post.keys():
103+
post = post["post"]
104+
cur_post["author"] = post["author"]["handle"]
105+
cur_post["text"] = post["record"]["text"]
106+
107+
# image handling
108+
if "embed" in post.keys():
109+
cid = post["cid"]
110+
if "images" in post["embed"].keys():
111+
cur_post["image_url"] = post["embed"]["images"][0]["thumb"]
112+
elif "thumbnail" in post["embed"].keys():
113+
cur_post["image_url"] = post["embed"]["thumbnail"]
114+
elif "external" in post["embed"].keys() and "thumb" in post["embed"]["external"].keys():
115+
cur_post["image_url"] = post["embed"]["external"]["thumb"]
116+
117+
# if we actually have an image to show
118+
if "image_url" in cur_post.keys():
119+
# check if we already downloaded this image
120+
if f"{cid}.jpg" not in os.listdir("static/imgs/"):
121+
print(f"downloading: {cur_post['image_url']}")
122+
123+
# download image and write to file
124+
img_resp = requests.get(cur_post["image_url"])
125+
with open(f"static/imgs/{cid}.jpg", "wb") as f:
126+
f.write(img_resp.content)
127+
128+
cur_post["image_file"] = f"{cid}.jpg"
129+
processed_posts["posts"].append(cur_post)
130+
131+
# save the processed data to a file
132+
with open(".data/processed_data.json", "w", encoding="utf-8") as f:
133+
f.write(json.dumps(processed_posts))
134+
135+
136+
def read_cached_data():
137+
"""
138+
Load the cached processed data file and return
139+
the data from within it.
140+
141+
:return: The posts data loaded from JSON
142+
"""
143+
with open(".data/processed_data.json", "r") as f:
144+
return json.load(f)
145+
146+
147+
class Api:
148+
"""
149+
API object for interaction between python code here
150+
and JS code running inside the page.
151+
"""
152+
153+
def get_posts(self):
154+
"""
155+
Fetch new posts data from Bluesky API, cache and return it.
156+
:return: Processed data containing everything necessary to show
157+
posts on the TFT.
158+
"""
159+
fetch_data(**FETCH_ARGS)
160+
return read_cached_data()
161+
162+
def check_quit(self):
163+
"""
164+
Allows the python code to correctly handle KeyboardInterrupt
165+
more quickly.
166+
167+
:return: None
168+
"""
169+
pass
170+
171+
172+
# create a webview and load the index.html page
173+
webview.create_window("bsky posts", "static/index.html",
174+
js_api=Api(), width=320, height=240)
175+
webview.start()
176+
# webview.start(debug=True) # use this one to enable chromium dev tools to see console.log() output from the page.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
sudo apt install fonts-noto-color-emoji
2+
sudo apt install python3-webview
3+
sudo apt install python3-requests
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Bluesky Posts</title>
6+
<style>
7+
.hidden {
8+
display: none;
9+
}
10+
11+
/* Scale image down to fit the full thing on the TFT */
12+
img {
13+
max-width: 304px;
14+
max-height: 240px;
15+
object-fit: contain;
16+
}
17+
18+
/* make really long handles wrap to next line instead of run off edge */
19+
.postAuthor{
20+
overflow-wrap: break-word;
21+
}
22+
23+
/* Hide scrollbar for Chrome, Safari and Opera */
24+
body::-webkit-scrollbar {
25+
display: none;
26+
}
27+
</style>
28+
</head>
29+
<body>
30+
31+
<!-- container to hold all posts -->
32+
<div id="postWall">
33+
34+
</div>
35+
36+
<!-- template element for a single post -->
37+
<div id="postTemplate" class="hidden">
38+
<h3 class="postAuthor"></h3>
39+
<p class="postText"></p>
40+
<img class="postImg">
41+
</div>
42+
43+
<!-- load the JS for the rest of the fun -->
44+
<script src="script.js"></script>
45+
</body>
46+
</html>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// SPDX-FileCopyrightText: 2024 Tim Cocks
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
/* bluesky scroller script.js */
6+
7+
// DOM Element references
8+
let $template = document.querySelector("#postTemplate");
9+
let $postWall = document.querySelector("#postWall");
10+
11+
// holds how many times we've fetched data. Used for filtering out older posts
12+
let curFetchIndex = 0;
13+
14+
// list that will hold new post objects that have been fetched
15+
let newPosts;
16+
17+
// flag to know whether the wall has been initialized
18+
let initializedWall = false;
19+
20+
// gets callback when pywebview Api object is ready to be used
21+
window.addEventListener('pywebviewready', function () {
22+
23+
function fetchNewPosts() {
24+
/* Fetch posts, then initialize the wall if it hasn't been yet */
25+
26+
pywebview.api.get_posts().then(function (posts) {
27+
console.log("fetching new data")
28+
if (!initializedWall) {
29+
buildPostWall(posts);
30+
31+
// start the autoscroller
32+
setTimeout(function(){setInterval(autoScroll, 50);}, 2000);
33+
34+
// set flag true so we know next time
35+
initializedWall = true
36+
37+
} else { // wall was initialized already
38+
// just update the newPosts list
39+
newPosts = posts;
40+
}
41+
42+
curFetchIndex += 1;
43+
});
44+
}
45+
46+
// call fetch the first time
47+
fetchNewPosts();
48+
49+
// set an interval to call fetch every 7 minutes
50+
setInterval(fetchNewPosts, 7 * 60 * 1000);
51+
})
52+
53+
function inflatePostTemplate(postObj) {
54+
/* Takes an object represent the post to show and inflates
55+
* DOM elements and populates them with the post data. */
56+
57+
let $post = $template.cloneNode(true);
58+
$post.removeAttribute("id");
59+
console.log($post);
60+
$post.setAttribute("data-fetch-index", curFetchIndex);
61+
$post.querySelector(".postAuthor").innerText = postObj["author"];
62+
$post.querySelector(".postText").innerText = postObj["text"];
63+
if(postObj.hasOwnProperty("image_file")){
64+
//$post.querySelector(".postImg").src = "../../.data/imgs/" + postObj["image_file"];
65+
$post.querySelector(".postImg").src = "imgs/" + postObj["image_file"];
66+
}else{
67+
$post.removeChild($post.querySelector(".postImg"));
68+
}
69+
70+
$post.classList.remove("hidden");
71+
return $post;
72+
}
73+
74+
function buildPostWall(posts) {
75+
/* Takes an object with a list of posts in it, inflates DOM elements
76+
* for each post in the data and adds it to the wall. */
77+
78+
for (let i = 0; i < posts["posts"].length; i++) {
79+
let $post = inflatePostTemplate(posts["posts"][i])
80+
$postWall.appendChild($post);
81+
}
82+
}
83+
84+
// gets callback any time a scroll event occurs
85+
window.addEventListener('scroll', function () {
86+
// if scroll is past the boundary line
87+
if (window.scrollY > 1000) {
88+
// get the first post element from the top of the wall
89+
let $firstPost = $postWall.firstElementChild
90+
// remove it from the wall
91+
$postWall.removeChild($firstPost);
92+
93+
// if there are no new posts currently
94+
if (newPosts === undefined || newPosts["posts"].length === 0) {
95+
// add the first post back to the wall at the bottom
96+
$postWall.appendChild($firstPost);
97+
98+
} else { // there are new posts to start showing
99+
100+
// inflate the first new post
101+
$newPost = inflatePostTemplate(newPosts["posts"].shift());
102+
// add it to the post wall
103+
$postWall.appendChild($newPost);
104+
105+
// if the post we removed from the top is still current
106+
if ($firstPost.getAttribute("data-fetch-index") === curFetchIndex) {
107+
// add it back in at the bottom
108+
$postWall.appendChild($firstPost);
109+
}
110+
}
111+
}
112+
});
113+
114+
function autoScroll() {
115+
/* Function to be called frequently to automatically scroll the page.
116+
* Also calls check_quit() to allow python to handle KeyboardInterrupt */
117+
pywebview.api.check_quit();
118+
window.scrollBy(0, 2);
119+
}

0 commit comments

Comments
 (0)