Skip to content

Commit 8e045c3

Browse files
authored
Merge pull request #2932 from FoamyGuy/pi_bluesky_tft_scroller
adding rpi bluesky tft scroller
2 parents 46621d0 + 41c84a4 commit 8e045c3

File tree

5 files changed

+371
-0
lines changed

5 files changed

+371
-0
lines changed

Pi_Bluesky_TFT_Scroller/.data/.gitkeep

Whitespace-only changes.

Pi_Bluesky_TFT_Scroller/code.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
import sys
11+
12+
import requests
13+
import webview
14+
15+
FEEDLINK_RETROCOMPUTING = (
16+
"https://bsky.app/profile/did:plc:tbo4hkau3p2itkar2vsnb3gp/feed/aaabo5oe7bzok"
17+
)
18+
19+
# Un-comment a single key inside of FEED_ARGS and set it's value to the feed, list or search
20+
# that you want to scroll.
21+
FETCH_ARGS = {
22+
# "feed_share_link": FEEDLINK_RETROCOMPUTING,
23+
# "feed_share_link": "https://bsky.app/profile/did:plc:463touruejpokvutnn5ikxb5/lists/3lbfdtahfzt2a", # pylint: disable=line-too-long
24+
# "search_args": {"q": "Adafruit", "sort": "latest"}
25+
"search_args": {"q": "#circuitpython", "sort": "latest"}
26+
}
27+
28+
29+
def at_feed_uri_from_share_link(share_link):
30+
"""
31+
Converts a share link into an AT URI for that resource.
32+
33+
:param share_link: The share link to convert.
34+
:return str: The AT URI pointing at the resource.
35+
"""
36+
at_feed_uri = share_link.replace("https://bsky.app/profile/", "at://")
37+
if "/feed/" in share_link:
38+
at_feed_uri = at_feed_uri.replace("/feed/", "/app.bsky.feed.generator/")
39+
if "/lists/" in share_link:
40+
at_feed_uri = at_feed_uri.replace("/lists/", "/app.bsky.graph.list/")
41+
return at_feed_uri
42+
43+
44+
def fetch_data(feed_share_link=None, search_args=None):
45+
"""
46+
Fetch posts from Bluesky API and write them into the local cached
47+
data files. After posts are written locally iterates over them
48+
and downloads the relevant photos from them.
49+
50+
Must pass either feed_share_link or search_args.
51+
52+
:param feed_share_link: The link copied from Bluesky front end to share the feed or list.
53+
:param search_args: A dictionary containing at minimum a ``q`` key with string value of
54+
the hashtag or term to search for. See bsky API docs for other supported keys.
55+
:return: None
56+
"""
57+
# pylint: disable=too-many-statements,too-many-branches
58+
if feed_share_link is None and search_args is None:
59+
# If both inputs are None, just use retrocomputing feed.
60+
feed_share_link = FEEDLINK_RETROCOMPUTING
61+
62+
# if a share link input was provided
63+
if feed_share_link is not None:
64+
FEED_AT = at_feed_uri_from_share_link(feed_share_link)
65+
# print(FEED_AT)
66+
67+
# if it's a feed
68+
if "/app.bsky.feed.generator/" in FEED_AT:
69+
URL = (f"https://public.api.bsky.app/xrpc/app.bsky.feed.getFeed?"
70+
f"feed={FEED_AT}&limit=30")
71+
headers = {"Accept-Language": "en"}
72+
resp = requests.get(URL, headers=headers)
73+
74+
# if it's a list
75+
elif "/app.bsky.graph.list/" in FEED_AT:
76+
URL = (f"https://public.api.bsky.app/xrpc/app.bsky.feed.getListFeed?"
77+
f"list={FEED_AT}&limit=30")
78+
headers = {"Accept-Language": "en"}
79+
resp = requests.get(URL, headers=headers)
80+
81+
# raise error if it's an unknown type
82+
else:
83+
raise ValueError(
84+
"Only 'app.bsky.feed.generator' and 'app.bsky.graph.list' URIs are supported."
85+
)
86+
87+
# if a search input was provided
88+
if search_args is not None:
89+
URL = "https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts"
90+
headers = {"Accept-Language": "en"}
91+
resp = requests.get(URL, headers=headers, params=search_args)
92+
93+
with open(".data/raw_data.json", "wb") as f:
94+
# write raw response to cache
95+
f.write(resp.content)
96+
97+
# Process the post data into a smaller subset
98+
# containing just the bits we need for showing
99+
# on the TFT.
100+
resp_json = json.loads(resp.text)
101+
processed_posts = {"posts": []}
102+
fetched_posts = None
103+
if "feed" in resp_json.keys():
104+
fetched_posts = resp_json["feed"]
105+
elif "posts" in resp_json.keys():
106+
fetched_posts = resp_json["posts"]
107+
108+
for post in fetched_posts:
109+
cur_post = {}
110+
if "post" in post.keys():
111+
post = post["post"]
112+
cur_post["author"] = post["author"]["handle"]
113+
cur_post["text"] = post["record"]["text"]
114+
115+
# image handling
116+
if "embed" in post.keys():
117+
cid = post["cid"]
118+
if "images" in post["embed"].keys():
119+
cur_post["image_url"] = post["embed"]["images"][0]["thumb"]
120+
elif "thumbnail" in post["embed"].keys():
121+
cur_post["image_url"] = post["embed"]["thumbnail"]
122+
elif (
123+
"external" in post["embed"].keys()
124+
and "thumb" in post["embed"]["external"].keys()
125+
):
126+
cur_post["image_url"] = post["embed"]["external"]["thumb"]
127+
128+
# if we actually have an image to show
129+
if "image_url" in cur_post.keys():
130+
# check if we already downloaded this image
131+
if f"{cid}.jpg" not in os.listdir("static/imgs/"):
132+
print(f"downloading: {cur_post['image_url']}")
133+
134+
# download image and write to file
135+
img_resp = requests.get(cur_post["image_url"])
136+
with open(f"static/imgs/{cid}.jpg", "wb") as f:
137+
f.write(img_resp.content)
138+
139+
cur_post["image_file"] = f"{cid}.jpg"
140+
processed_posts["posts"].append(cur_post)
141+
142+
# save the processed data to a file
143+
with open(".data/processed_data.json", "w", encoding="utf-8") as f:
144+
f.write(json.dumps(processed_posts))
145+
146+
147+
def read_cached_data():
148+
"""
149+
Load the cached processed data file and return
150+
the data from within it.
151+
152+
:return: The posts data loaded from JSON
153+
"""
154+
with open(".data/processed_data.json", "r") as f:
155+
return json.load(f)
156+
157+
158+
class Api:
159+
"""
160+
API object for interaction between python code here
161+
and JS code running inside the page.
162+
"""
163+
164+
# pylint: disable=no-self-use
165+
def get_posts(self):
166+
"""
167+
Fetch new posts data from Bluesky API, cache and return it.
168+
:return: Processed data containing everything necessary to show
169+
posts on the TFT.
170+
"""
171+
fetch_data(**FETCH_ARGS)
172+
return read_cached_data()
173+
174+
def check_quit(self):
175+
"""
176+
Allows the python code to correctly handle KeyboardInterrupt
177+
more quickly.
178+
179+
:return: None
180+
"""
181+
# pylint: disable=unnecessary-pass
182+
pass
183+
184+
def quit(self):
185+
window.destroy()
186+
sys.exit(0)
187+
188+
189+
# create a webview and load the index.html page
190+
window = webview.create_window(
191+
"bsky posts", "static/index.html", js_api=Api(), width=320, height=240,
192+
x=0, y=0, frameless=True, fullscreen=True
193+
194+
)
195+
webview.start()
196+
# 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: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
}
120+
121+
document.addEventListener('keydown', function(event){
122+
/* Quit if the user presses esc key */
123+
if (event.key === "Escape"){
124+
pywebview.api.quit();
125+
}
126+
} );

0 commit comments

Comments
 (0)