fetchtastic 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +0 -0
- app/cli.py +57 -0
- app/downloader.py +256 -0
- app/menu_apk.py +40 -0
- app/menu_firmware.py +39 -0
- app/setup_config.py +288 -0
- fetchtastic-0.1.0.dist-info/LICENSE +21 -0
- fetchtastic-0.1.0.dist-info/METADATA +117 -0
- fetchtastic-0.1.0.dist-info/RECORD +12 -0
- fetchtastic-0.1.0.dist-info/WHEEL +5 -0
- fetchtastic-0.1.0.dist-info/entry_points.txt +2 -0
- fetchtastic-0.1.0.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
|
File without changes
|
app/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# app/cli.py
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from . import downloader
|
|
5
|
+
from . import setup_config
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(description="Fetchtastic - Meshtastic Firmware and APK Downloader")
|
|
9
|
+
subparsers = parser.add_subparsers(dest='command')
|
|
10
|
+
|
|
11
|
+
# Command to run setup
|
|
12
|
+
subparsers.add_parser('setup', help='Run the setup process')
|
|
13
|
+
|
|
14
|
+
# Command to download firmware and APKs
|
|
15
|
+
subparsers.add_parser('download', help='Download firmware and APKs')
|
|
16
|
+
|
|
17
|
+
# Command to display NTFY topic
|
|
18
|
+
subparsers.add_parser('topic', help='Display the current NTFY topic')
|
|
19
|
+
|
|
20
|
+
# Command to clean/remove Fetchtastic files and settings
|
|
21
|
+
subparsers.add_parser('clean', help='Remove Fetchtastic configuration, downloads, and cron jobs')
|
|
22
|
+
|
|
23
|
+
args = parser.parse_args()
|
|
24
|
+
|
|
25
|
+
if args.command == 'setup':
|
|
26
|
+
# Run the setup process
|
|
27
|
+
setup_config.run_setup()
|
|
28
|
+
elif args.command == 'download':
|
|
29
|
+
# Check if configuration exists
|
|
30
|
+
if not setup_config.config_exists():
|
|
31
|
+
print("No configuration found. Running setup.")
|
|
32
|
+
setup_config.run_setup()
|
|
33
|
+
# Run the downloader
|
|
34
|
+
downloader.main()
|
|
35
|
+
elif args.command == 'topic':
|
|
36
|
+
# Display the NTFY topic
|
|
37
|
+
config = setup_config.load_config()
|
|
38
|
+
if config and config.get('NTFY_SERVER') and config.get('NTFY_TOPIC'):
|
|
39
|
+
ntfy_server = config['NTFY_SERVER'].rstrip('/')
|
|
40
|
+
ntfy_topic = config['NTFY_TOPIC']
|
|
41
|
+
full_url = f"{ntfy_server}/{ntfy_topic}"
|
|
42
|
+
print(f"Current NTFY topic URL: {full_url}")
|
|
43
|
+
print(f"Topic name: {ntfy_topic}")
|
|
44
|
+
else:
|
|
45
|
+
print("Notifications are not set up. Run 'fetchtastic setup' to configure notifications.")
|
|
46
|
+
elif args.command == 'clean':
|
|
47
|
+
# Run the clean process
|
|
48
|
+
setup_config.run_clean()
|
|
49
|
+
elif args.command is None:
|
|
50
|
+
# No command provided
|
|
51
|
+
print("No command provided.")
|
|
52
|
+
print("For help and available commands, run 'fetchtastic --help'.")
|
|
53
|
+
else:
|
|
54
|
+
parser.print_help()
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
app/downloader.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# app/downloader.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import requests
|
|
5
|
+
import zipfile
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from requests.adapters import HTTPAdapter
|
|
9
|
+
from urllib3.util.retry import Retry
|
|
10
|
+
|
|
11
|
+
from . import setup_config
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
# Load configuration
|
|
15
|
+
config = setup_config.load_config()
|
|
16
|
+
if not config:
|
|
17
|
+
print("Configuration not found. Please run 'fetchtastic setup' first.")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
# Get configuration values
|
|
21
|
+
save_apks = config.get("SAVE_APKS", False)
|
|
22
|
+
save_firmware = config.get("SAVE_FIRMWARE", False)
|
|
23
|
+
ntfy_server = config.get("NTFY_SERVER", "")
|
|
24
|
+
ntfy_topic = config.get("NTFY_TOPIC", "")
|
|
25
|
+
android_versions_to_keep = config.get("ANDROID_VERSIONS_TO_KEEP", 2)
|
|
26
|
+
firmware_versions_to_keep = config.get("FIRMWARE_VERSIONS_TO_KEEP", 2)
|
|
27
|
+
auto_extract = config.get("AUTO_EXTRACT", False)
|
|
28
|
+
extract_patterns = config.get("EXTRACT_PATTERNS", [])
|
|
29
|
+
|
|
30
|
+
selected_apk_assets = config.get('SELECTED_APK_ASSETS', [])
|
|
31
|
+
selected_firmware_assets = config.get('SELECTED_FIRMWARE_ASSETS', [])
|
|
32
|
+
|
|
33
|
+
download_dir = config.get('DOWNLOAD_DIR', os.path.join(os.path.expanduser("~"), "storage", "downloads", "Meshtastic"))
|
|
34
|
+
firmware_dir = os.path.join(download_dir, "firmware")
|
|
35
|
+
apks_dir = os.path.join(download_dir, "apks")
|
|
36
|
+
latest_android_release_file = os.path.join(apks_dir, "latest_android_release.txt")
|
|
37
|
+
latest_firmware_release_file = os.path.join(firmware_dir, "latest_firmware_release.txt")
|
|
38
|
+
|
|
39
|
+
# Create necessary directories
|
|
40
|
+
for dir_path in [download_dir, firmware_dir, apks_dir]:
|
|
41
|
+
if not os.path.exists(dir_path):
|
|
42
|
+
os.makedirs(dir_path)
|
|
43
|
+
|
|
44
|
+
# Logging setup
|
|
45
|
+
log_file = os.path.join(download_dir, "fetchtastic.log")
|
|
46
|
+
|
|
47
|
+
def log_message(message):
|
|
48
|
+
with open(log_file, "a") as log:
|
|
49
|
+
log.write(f"{datetime.now()}: {message}\n")
|
|
50
|
+
print(message)
|
|
51
|
+
|
|
52
|
+
def send_ntfy_notification(message):
|
|
53
|
+
if ntfy_server and ntfy_topic:
|
|
54
|
+
try:
|
|
55
|
+
ntfy_url = f"{ntfy_server.rstrip('/')}/{ntfy_topic}"
|
|
56
|
+
response = requests.post(ntfy_url, data=message.encode('utf-8'))
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
except requests.exceptions.RequestException as e:
|
|
59
|
+
log_message(f"Error sending notification: {e}")
|
|
60
|
+
else:
|
|
61
|
+
log_message("Notifications are not configured.")
|
|
62
|
+
|
|
63
|
+
# Function to get the latest releases and sort by date
|
|
64
|
+
def get_latest_releases(url, versions_to_keep, scan_count=5):
|
|
65
|
+
response = requests.get(url)
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
releases = response.json()
|
|
68
|
+
# Sort releases by published date, descending order
|
|
69
|
+
sorted_releases = sorted(releases, key=lambda r: r['published_at'], reverse=True)
|
|
70
|
+
# Limit the number of releases to be scanned and downloaded
|
|
71
|
+
return sorted_releases[:scan_count][:versions_to_keep]
|
|
72
|
+
|
|
73
|
+
# Function to download a file with retry mechanism
|
|
74
|
+
def download_file(url, download_path):
|
|
75
|
+
session = requests.Session()
|
|
76
|
+
retry = Retry(connect=3, backoff_factor=1, status_forcelist=[502, 503, 504])
|
|
77
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
78
|
+
session.mount('https://', adapter)
|
|
79
|
+
session.mount('http://', adapter)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
if not os.path.exists(download_path):
|
|
83
|
+
log_message(f"Downloading {url}")
|
|
84
|
+
response = session.get(url, stream=True)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
with open(download_path, 'wb') as file:
|
|
87
|
+
for chunk in response.iter_content(1024):
|
|
88
|
+
file.write(chunk)
|
|
89
|
+
log_message(f"Downloaded {download_path}")
|
|
90
|
+
else:
|
|
91
|
+
log_message(f"{download_path} already exists, skipping download.")
|
|
92
|
+
except requests.exceptions.RequestException as e:
|
|
93
|
+
log_message(f"Error downloading {url}: {e}")
|
|
94
|
+
|
|
95
|
+
# Updated extract_files function
|
|
96
|
+
def extract_files(zip_path, extract_dir, patterns):
|
|
97
|
+
try:
|
|
98
|
+
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
99
|
+
matched_files = []
|
|
100
|
+
for file_info in zip_ref.infolist():
|
|
101
|
+
file_name = file_info.filename
|
|
102
|
+
base_name = os.path.basename(file_name)
|
|
103
|
+
log_message(f"Checking file: {base_name}")
|
|
104
|
+
for pattern in patterns:
|
|
105
|
+
if pattern in base_name:
|
|
106
|
+
# Extract and flatten directory structure
|
|
107
|
+
source = zip_ref.open(file_info)
|
|
108
|
+
target_path = os.path.join(extract_dir, base_name)
|
|
109
|
+
with open(target_path, 'wb') as target_file:
|
|
110
|
+
target_file.write(source.read())
|
|
111
|
+
log_message(f"Extracted {base_name} to {extract_dir}")
|
|
112
|
+
matched_files.append(base_name)
|
|
113
|
+
break # Stop checking patterns for this file
|
|
114
|
+
if not matched_files:
|
|
115
|
+
log_message(f"No files matched the extraction patterns in {zip_path}.")
|
|
116
|
+
except zipfile.BadZipFile:
|
|
117
|
+
log_message(f"Error: {zip_path} is a bad zip file and cannot be opened.")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
log_message(f"Error: An unexpected error occurred while extracting files from {zip_path}: {e}")
|
|
120
|
+
|
|
121
|
+
# Cleanup function to keep only a specific number of versions
|
|
122
|
+
def cleanup_old_versions(directory, keep_count):
|
|
123
|
+
versions = sorted(
|
|
124
|
+
(os.path.join(directory, d) for d in os.listdir(directory) if os.path.isdir(os.path.join(directory, d))),
|
|
125
|
+
key=os.path.getmtime
|
|
126
|
+
)
|
|
127
|
+
old_versions = versions[:-keep_count]
|
|
128
|
+
for version in old_versions:
|
|
129
|
+
for root, dirs, files in os.walk(version, topdown=False):
|
|
130
|
+
for name in files:
|
|
131
|
+
os.remove(os.path.join(root, name))
|
|
132
|
+
log_message(f"Removed file: {os.path.join(root, name)}")
|
|
133
|
+
for name in dirs:
|
|
134
|
+
os.rmdir(os.path.join(root, name))
|
|
135
|
+
os.rmdir(version)
|
|
136
|
+
log_message(f"Removed directory: {version}")
|
|
137
|
+
|
|
138
|
+
# Function to check for missing releases and download them if necessary
|
|
139
|
+
def check_and_download(releases, latest_release_file, release_type, download_dir, versions_to_keep, extract_patterns, selected_assets=None):
|
|
140
|
+
downloaded_versions = []
|
|
141
|
+
|
|
142
|
+
if not os.path.exists(download_dir):
|
|
143
|
+
os.makedirs(download_dir)
|
|
144
|
+
|
|
145
|
+
# Load the latest release tag from file if available
|
|
146
|
+
saved_release_tag = None
|
|
147
|
+
if os.path.exists(latest_release_file):
|
|
148
|
+
with open(latest_release_file, 'r') as f:
|
|
149
|
+
saved_release_tag = f.read().strip()
|
|
150
|
+
|
|
151
|
+
# Determine which releases to download
|
|
152
|
+
for release in releases:
|
|
153
|
+
release_tag = release['tag_name']
|
|
154
|
+
release_dir = os.path.join(download_dir, release_tag)
|
|
155
|
+
|
|
156
|
+
if os.path.exists(release_dir) or release_tag == saved_release_tag:
|
|
157
|
+
log_message(f"Skipping version {release_tag}, already exists.")
|
|
158
|
+
else:
|
|
159
|
+
# Proceed to download this version
|
|
160
|
+
os.makedirs(release_dir, exist_ok=True)
|
|
161
|
+
log_message(f"Downloading new version: {release_tag}")
|
|
162
|
+
for asset in release['assets']:
|
|
163
|
+
file_name = asset['name']
|
|
164
|
+
if selected_assets:
|
|
165
|
+
if file_name not in selected_assets:
|
|
166
|
+
continue
|
|
167
|
+
download_path = os.path.join(release_dir, file_name)
|
|
168
|
+
download_file(asset['browser_download_url'], download_path)
|
|
169
|
+
if auto_extract and file_name.endswith('.zip') and release_type == "Firmware":
|
|
170
|
+
extract_files(download_path, release_dir, extract_patterns)
|
|
171
|
+
downloaded_versions.append(release_tag)
|
|
172
|
+
|
|
173
|
+
# Update latest_release_file with the most recent tag
|
|
174
|
+
if releases:
|
|
175
|
+
with open(latest_release_file, 'w') as f:
|
|
176
|
+
f.write(releases[0]['tag_name'])
|
|
177
|
+
|
|
178
|
+
# Clean up old versions
|
|
179
|
+
cleanup_old_versions(download_dir, versions_to_keep)
|
|
180
|
+
return downloaded_versions
|
|
181
|
+
|
|
182
|
+
start_time = time.time()
|
|
183
|
+
log_message("Starting Fetchtastic...")
|
|
184
|
+
|
|
185
|
+
downloaded_firmwares = []
|
|
186
|
+
downloaded_apks = []
|
|
187
|
+
|
|
188
|
+
# URLs for releases
|
|
189
|
+
android_releases_url = "https://api.github.com/repos/meshtastic/Meshtastic-Android/releases"
|
|
190
|
+
firmware_releases_url = "https://api.github.com/repos/meshtastic/firmware/releases"
|
|
191
|
+
|
|
192
|
+
# Scan for the last 5 releases, download the latest versions_to_download
|
|
193
|
+
releases_to_scan = 5
|
|
194
|
+
|
|
195
|
+
latest_firmware_releases = []
|
|
196
|
+
latest_android_releases = []
|
|
197
|
+
|
|
198
|
+
if save_firmware and selected_firmware_assets:
|
|
199
|
+
versions_to_download = firmware_versions_to_keep
|
|
200
|
+
latest_firmware_releases = get_latest_releases(firmware_releases_url, versions_to_download, releases_to_scan)
|
|
201
|
+
downloaded_firmwares = check_and_download(
|
|
202
|
+
latest_firmware_releases,
|
|
203
|
+
latest_firmware_release_file,
|
|
204
|
+
"Firmware",
|
|
205
|
+
firmware_dir,
|
|
206
|
+
firmware_versions_to_keep,
|
|
207
|
+
extract_patterns,
|
|
208
|
+
selected_assets=selected_firmware_assets
|
|
209
|
+
)
|
|
210
|
+
log_message(f"Latest Firmware releases: {', '.join(release['tag_name'] for release in latest_firmware_releases)}")
|
|
211
|
+
elif not selected_firmware_assets:
|
|
212
|
+
log_message("No firmware assets selected. Skipping firmware download.")
|
|
213
|
+
|
|
214
|
+
if save_apks and selected_apk_assets:
|
|
215
|
+
versions_to_download = android_versions_to_keep
|
|
216
|
+
latest_android_releases = get_latest_releases(android_releases_url, versions_to_download, releases_to_scan)
|
|
217
|
+
downloaded_apks = check_and_download(
|
|
218
|
+
latest_android_releases,
|
|
219
|
+
latest_android_release_file,
|
|
220
|
+
"Android APK",
|
|
221
|
+
apks_dir,
|
|
222
|
+
android_versions_to_keep,
|
|
223
|
+
extract_patterns,
|
|
224
|
+
selected_assets=selected_apk_assets
|
|
225
|
+
)
|
|
226
|
+
log_message(f"Latest Android APK releases: {', '.join(release['tag_name'] for release in latest_android_releases)}")
|
|
227
|
+
elif not selected_apk_assets:
|
|
228
|
+
log_message("No APK assets selected. Skipping APK download.")
|
|
229
|
+
|
|
230
|
+
end_time = time.time()
|
|
231
|
+
total_time = end_time - start_time
|
|
232
|
+
log_message(f"Finished the Meshtastic downloader. Total time taken: {total_time:.2f} seconds")
|
|
233
|
+
|
|
234
|
+
# Send notification if there are new downloads
|
|
235
|
+
if downloaded_firmwares or downloaded_apks:
|
|
236
|
+
message = ""
|
|
237
|
+
if downloaded_firmwares:
|
|
238
|
+
message += f"New Firmware releases {', '.join(downloaded_firmwares)} downloaded.\n"
|
|
239
|
+
if downloaded_apks:
|
|
240
|
+
message += f"New Android APK releases {', '.join(downloaded_apks)} downloaded.\n"
|
|
241
|
+
message += f"{datetime.now()}"
|
|
242
|
+
send_ntfy_notification(message)
|
|
243
|
+
else:
|
|
244
|
+
if latest_firmware_releases or latest_android_releases:
|
|
245
|
+
message = (
|
|
246
|
+
f"All Firmware and Android APK versions are up to date.\n"
|
|
247
|
+
f"Latest Firmware releases: {', '.join(release['tag_name'] for release in latest_firmware_releases)}\n"
|
|
248
|
+
f"Latest Android APK releases: {', '.join(release['tag_name'] for release in latest_android_releases)}\n"
|
|
249
|
+
f"{datetime.now()}"
|
|
250
|
+
)
|
|
251
|
+
send_ntfy_notification(message)
|
|
252
|
+
else:
|
|
253
|
+
log_message("No releases found to check for updates.")
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
main()
|
app/menu_apk.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# fetchtastic/menu_apk.py
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import requests
|
|
5
|
+
from pick import pick
|
|
6
|
+
|
|
7
|
+
def fetch_apk_assets():
|
|
8
|
+
apk_releases_url = "https://api.github.com/repos/meshtastic/Meshtastic-Android/releases"
|
|
9
|
+
response = requests.get(apk_releases_url)
|
|
10
|
+
response.raise_for_status()
|
|
11
|
+
releases = response.json()
|
|
12
|
+
# Get the latest release
|
|
13
|
+
latest_release = releases[0]
|
|
14
|
+
assets = latest_release['assets']
|
|
15
|
+
asset_names = [asset['name'] for asset in assets if asset['name'].endswith('.apk')]
|
|
16
|
+
return asset_names
|
|
17
|
+
|
|
18
|
+
def select_assets(assets):
|
|
19
|
+
title = '''Select the APK files you want to download (press SPACE to select, ENTER to confirm):
|
|
20
|
+
Note: These are files from the latest release. Version numbers may change in other releases.'''
|
|
21
|
+
options = assets
|
|
22
|
+
selected_options = pick(options, title, multiselect=True, min_selection_count=0, indicator='*')
|
|
23
|
+
selected_assets = [option[0] for option in selected_options]
|
|
24
|
+
if not selected_assets:
|
|
25
|
+
print("No APK files selected. APKs will not be downloaded.")
|
|
26
|
+
return None
|
|
27
|
+
return selected_assets
|
|
28
|
+
|
|
29
|
+
def run_menu():
|
|
30
|
+
try:
|
|
31
|
+
assets = fetch_apk_assets()
|
|
32
|
+
selected_assets = select_assets(assets)
|
|
33
|
+
if selected_assets is None:
|
|
34
|
+
return None
|
|
35
|
+
return {
|
|
36
|
+
'selected_assets': selected_assets
|
|
37
|
+
}
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print(f"An error occurred: {e}")
|
|
40
|
+
return None
|
app/menu_firmware.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# app/menu_firmware.py
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from pick import pick
|
|
5
|
+
|
|
6
|
+
def fetch_firmware_assets():
|
|
7
|
+
firmware_releases_url = "https://api.github.com/repos/meshtastic/firmware/releases"
|
|
8
|
+
response = requests.get(firmware_releases_url)
|
|
9
|
+
response.raise_for_status()
|
|
10
|
+
releases = response.json()
|
|
11
|
+
# Get the latest release
|
|
12
|
+
latest_release = releases[0]
|
|
13
|
+
assets = latest_release['assets']
|
|
14
|
+
asset_names = [asset['name'] for asset in assets]
|
|
15
|
+
return asset_names
|
|
16
|
+
|
|
17
|
+
def select_assets(assets):
|
|
18
|
+
title = '''Select the firmware files you want to download (press SPACE to select, ENTER to confirm):
|
|
19
|
+
Note: These are files from the latest release. Version numbers may change in other releases.'''
|
|
20
|
+
options = assets
|
|
21
|
+
selected_options = pick(options, title, multiselect=True, min_selection_count=0, indicator='*')
|
|
22
|
+
selected_assets = [option[0] for option in selected_options]
|
|
23
|
+
if not selected_assets:
|
|
24
|
+
print("No firmware files selected. Firmware will not be downloaded.")
|
|
25
|
+
return None
|
|
26
|
+
return selected_assets
|
|
27
|
+
|
|
28
|
+
def run_menu():
|
|
29
|
+
try:
|
|
30
|
+
assets = fetch_firmware_assets()
|
|
31
|
+
selected_assets = select_assets(assets)
|
|
32
|
+
if selected_assets is None:
|
|
33
|
+
return None
|
|
34
|
+
return {
|
|
35
|
+
'selected_assets': selected_assets
|
|
36
|
+
}
|
|
37
|
+
except Exception as e:
|
|
38
|
+
print(f"An error occurred: {e}")
|
|
39
|
+
return None
|
app/setup_config.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# app/setup_config.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import yaml
|
|
5
|
+
import subprocess
|
|
6
|
+
import random
|
|
7
|
+
import string
|
|
8
|
+
import shutil # Added for shutil.which()
|
|
9
|
+
from . import menu_apk
|
|
10
|
+
from . import menu_firmware
|
|
11
|
+
from . import downloader # Import downloader to perform first run
|
|
12
|
+
|
|
13
|
+
def get_downloads_dir():
|
|
14
|
+
# For Termux, use ~/storage/downloads
|
|
15
|
+
if 'com.termux' in os.environ.get('PREFIX', ''):
|
|
16
|
+
storage_downloads = os.path.expanduser("~/storage/downloads")
|
|
17
|
+
if os.path.exists(storage_downloads):
|
|
18
|
+
return storage_downloads
|
|
19
|
+
# For other environments, use standard Downloads directories
|
|
20
|
+
home_dir = os.path.expanduser("~")
|
|
21
|
+
downloads_dir = os.path.join(home_dir, 'Downloads')
|
|
22
|
+
if os.path.exists(downloads_dir):
|
|
23
|
+
return downloads_dir
|
|
24
|
+
downloads_dir = os.path.join(home_dir, 'Download')
|
|
25
|
+
if os.path.exists(downloads_dir):
|
|
26
|
+
return downloads_dir
|
|
27
|
+
# Fallback to home directory
|
|
28
|
+
return home_dir
|
|
29
|
+
|
|
30
|
+
DOWNLOADS_DIR = get_downloads_dir()
|
|
31
|
+
DEFAULT_CONFIG_DIR = os.path.join(DOWNLOADS_DIR, 'Meshtastic')
|
|
32
|
+
CONFIG_FILE = os.path.join(DEFAULT_CONFIG_DIR, 'fetchtastic.yaml')
|
|
33
|
+
|
|
34
|
+
def config_exists():
|
|
35
|
+
return os.path.exists(CONFIG_FILE)
|
|
36
|
+
|
|
37
|
+
def run_setup():
|
|
38
|
+
# Check if running in Termux
|
|
39
|
+
if not is_termux():
|
|
40
|
+
print("Warning: Fetchtastic is designed to run in Termux on Android.")
|
|
41
|
+
print("For more information, visit https://github.com/jeremiah-k/fetchtastic/")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
print("Running Fetchtastic Setup...")
|
|
45
|
+
if not os.path.exists(DEFAULT_CONFIG_DIR):
|
|
46
|
+
os.makedirs(DEFAULT_CONFIG_DIR)
|
|
47
|
+
|
|
48
|
+
config = {}
|
|
49
|
+
|
|
50
|
+
# Prompt to save APKs, firmware, or both
|
|
51
|
+
save_choice = input("Do you want to save APKs, firmware, or both? [a/f/b] (default: b): ").strip().lower() or 'b'
|
|
52
|
+
if save_choice == 'a':
|
|
53
|
+
save_apks = True
|
|
54
|
+
save_firmware = False
|
|
55
|
+
elif save_choice == 'f':
|
|
56
|
+
save_apks = False
|
|
57
|
+
save_firmware = True
|
|
58
|
+
else:
|
|
59
|
+
save_apks = True
|
|
60
|
+
save_firmware = True
|
|
61
|
+
config['SAVE_APKS'] = save_apks
|
|
62
|
+
config['SAVE_FIRMWARE'] = save_firmware
|
|
63
|
+
|
|
64
|
+
# Run the menu scripts based on user choices
|
|
65
|
+
if save_apks:
|
|
66
|
+
apk_selection = menu_apk.run_menu()
|
|
67
|
+
if not apk_selection:
|
|
68
|
+
print("No APK assets selected. APKs will not be downloaded.")
|
|
69
|
+
save_apks = False
|
|
70
|
+
config['SAVE_APKS'] = False
|
|
71
|
+
else:
|
|
72
|
+
config['SELECTED_APK_ASSETS'] = apk_selection['selected_assets']
|
|
73
|
+
if save_firmware:
|
|
74
|
+
firmware_selection = menu_firmware.run_menu()
|
|
75
|
+
if not firmware_selection:
|
|
76
|
+
print("No firmware assets selected. Firmware will not be downloaded.")
|
|
77
|
+
save_firmware = False
|
|
78
|
+
config['SAVE_FIRMWARE'] = False
|
|
79
|
+
else:
|
|
80
|
+
config['SELECTED_FIRMWARE_ASSETS'] = firmware_selection['selected_assets']
|
|
81
|
+
|
|
82
|
+
# If both save_apks and save_firmware are False, inform the user and exit setup
|
|
83
|
+
if not save_apks and not save_firmware:
|
|
84
|
+
print("You must select at least one asset to download (APK or firmware).")
|
|
85
|
+
print("Please run 'fetchtastic setup' again and select at least one asset.")
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Prompt for number of versions to keep
|
|
89
|
+
if save_apks:
|
|
90
|
+
android_versions_to_keep = input("Enter the number of different versions of the Android app to keep (default: 2): ").strip() or '2'
|
|
91
|
+
config['ANDROID_VERSIONS_TO_KEEP'] = int(android_versions_to_keep)
|
|
92
|
+
if save_firmware:
|
|
93
|
+
firmware_versions_to_keep = input("Enter the number of different versions of the firmware to keep (default: 2): ").strip() or '2'
|
|
94
|
+
config['FIRMWARE_VERSIONS_TO_KEEP'] = int(firmware_versions_to_keep)
|
|
95
|
+
|
|
96
|
+
# Prompt for automatic extraction
|
|
97
|
+
auto_extract = input("Do you want to automatically extract specific files from firmware zips? [y/n] (default: n): ").strip().lower() or 'n'
|
|
98
|
+
if auto_extract == 'y':
|
|
99
|
+
print("Enter the strings to match for extraction from the firmware .zip files, separated by spaces.")
|
|
100
|
+
print("Example: rak4631- tbeam-2 t1000-e- tlora-v2-1-1_6-")
|
|
101
|
+
extract_patterns = input("Extraction patterns: ").strip()
|
|
102
|
+
if extract_patterns:
|
|
103
|
+
config['AUTO_EXTRACT'] = True
|
|
104
|
+
config['EXTRACT_PATTERNS'] = extract_patterns.split()
|
|
105
|
+
else:
|
|
106
|
+
config['AUTO_EXTRACT'] = False
|
|
107
|
+
else:
|
|
108
|
+
config['AUTO_EXTRACT'] = False
|
|
109
|
+
|
|
110
|
+
# Set the download directory to the same as the config directory
|
|
111
|
+
download_dir = DEFAULT_CONFIG_DIR
|
|
112
|
+
config['DOWNLOAD_DIR'] = download_dir
|
|
113
|
+
|
|
114
|
+
# Save configuration to YAML file before proceeding
|
|
115
|
+
with open(CONFIG_FILE, 'w') as f:
|
|
116
|
+
yaml.dump(config, f)
|
|
117
|
+
|
|
118
|
+
# Ask if the user wants to set up a cron job
|
|
119
|
+
setup_cron = input("Do you want to add a cron job to run Fetchtastic daily at 3 AM? [y/n] (default: y): ").strip().lower() or 'y'
|
|
120
|
+
if setup_cron == 'y':
|
|
121
|
+
# Install crond if not already installed
|
|
122
|
+
install_crond()
|
|
123
|
+
# Call function to set up cron job
|
|
124
|
+
setup_cron_job()
|
|
125
|
+
else:
|
|
126
|
+
print("Skipping cron job setup.")
|
|
127
|
+
|
|
128
|
+
# Prompt for NTFY server configuration
|
|
129
|
+
notifications = input("Do you want to set up notifications via NTFY? [y/n] (default: y): ").strip().lower() or 'y'
|
|
130
|
+
if notifications == 'y':
|
|
131
|
+
ntfy_server = input("Enter the NTFY server (default: ntfy.sh): ").strip() or 'ntfy.sh'
|
|
132
|
+
if not ntfy_server.startswith('http://') and not ntfy_server.startswith('https://'):
|
|
133
|
+
ntfy_server = 'https://' + ntfy_server
|
|
134
|
+
|
|
135
|
+
# Generate a random topic name if the user doesn't provide one
|
|
136
|
+
default_topic = 'fetchtastic-' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
|
137
|
+
topic_name = input(f"Enter a unique topic name (default: {default_topic}): ").strip() or default_topic
|
|
138
|
+
# Save only the topic name in the config
|
|
139
|
+
config['NTFY_TOPIC'] = topic_name
|
|
140
|
+
config['NTFY_SERVER'] = ntfy_server
|
|
141
|
+
|
|
142
|
+
# Save updated configuration
|
|
143
|
+
with open(CONFIG_FILE, 'w') as f:
|
|
144
|
+
yaml.dump(config, f)
|
|
145
|
+
|
|
146
|
+
full_topic_url = f"{ntfy_server.rstrip('/')}/{topic_name}"
|
|
147
|
+
print(f"Notifications have been set up using the topic: {topic_name}")
|
|
148
|
+
print(f"You can subscribe to this topic in the ntfy app easily by pasting the topic name.")
|
|
149
|
+
print(f"Full topic URL: {full_topic_url}")
|
|
150
|
+
# Ask if the user wants to copy the topic name to the clipboard
|
|
151
|
+
copy_to_clipboard = input("Do you want to copy the topic name to the clipboard? [y/n] (default: y): ").strip().lower() or 'y'
|
|
152
|
+
if copy_to_clipboard == 'y':
|
|
153
|
+
copy_to_clipboard_termux(topic_name)
|
|
154
|
+
print("Topic name copied to clipboard.")
|
|
155
|
+
else:
|
|
156
|
+
print("You can copy the topic name from above.")
|
|
157
|
+
|
|
158
|
+
print("You can view your current topic at any time by running 'fetchtastic topic'.")
|
|
159
|
+
print("You can change the topic by running 'fetchtastic setup' again or editing the YAML file.")
|
|
160
|
+
else:
|
|
161
|
+
config['NTFY_TOPIC'] = ''
|
|
162
|
+
config['NTFY_SERVER'] = ''
|
|
163
|
+
# Save updated configuration
|
|
164
|
+
with open(CONFIG_FILE, 'w') as f:
|
|
165
|
+
yaml.dump(config, f)
|
|
166
|
+
print("Notifications have not been set up.")
|
|
167
|
+
|
|
168
|
+
# Ask if the user wants to perform a first run
|
|
169
|
+
perform_first_run = input("Do you want to perform a first run now? [y/n] (default: y): ").strip().lower() or 'y'
|
|
170
|
+
if perform_first_run == 'y':
|
|
171
|
+
print("Performing first run, this may take a few minutes...")
|
|
172
|
+
downloader.main()
|
|
173
|
+
else:
|
|
174
|
+
print("Setup complete. You can run 'fetchtastic download' to start downloading.")
|
|
175
|
+
|
|
176
|
+
def is_termux():
|
|
177
|
+
return 'com.termux' in os.environ.get('PREFIX', '')
|
|
178
|
+
|
|
179
|
+
def copy_to_clipboard_termux(text):
|
|
180
|
+
try:
|
|
181
|
+
subprocess.run(['termux-clipboard-set'], input=text.encode('utf-8'), check=True)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
print(f"An error occurred while copying to clipboard: {e}")
|
|
184
|
+
|
|
185
|
+
def install_crond():
|
|
186
|
+
try:
|
|
187
|
+
# Check if crond is installed
|
|
188
|
+
crond_path = shutil.which('crond')
|
|
189
|
+
if crond_path is None:
|
|
190
|
+
print("Installing crond...")
|
|
191
|
+
subprocess.run(['pkg', 'install', 'termux-services', '-y'], check=True)
|
|
192
|
+
subprocess.run(['sv-enable', 'crond'], check=True)
|
|
193
|
+
print("crond installed and started.")
|
|
194
|
+
else:
|
|
195
|
+
print("crond is already installed.")
|
|
196
|
+
except Exception as e:
|
|
197
|
+
print(f"An error occurred while installing crond: {e}")
|
|
198
|
+
|
|
199
|
+
def setup_cron_job():
|
|
200
|
+
try:
|
|
201
|
+
# Get current crontab entries
|
|
202
|
+
result = subprocess.run(['crontab', '-l'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
203
|
+
if result.returncode != 0:
|
|
204
|
+
existing_cron = ''
|
|
205
|
+
else:
|
|
206
|
+
existing_cron = result.stdout
|
|
207
|
+
|
|
208
|
+
# Check for existing cron jobs related to fetchtastic
|
|
209
|
+
if 'fetchtastic download' in existing_cron:
|
|
210
|
+
print("An existing cron job for Fetchtastic was found:")
|
|
211
|
+
print(existing_cron)
|
|
212
|
+
keep_cron = input("Do you want to keep the existing crontab entry? [y/n] (default: y): ").strip().lower() or 'y'
|
|
213
|
+
if keep_cron == 'n':
|
|
214
|
+
# Remove existing fetchtastic cron jobs
|
|
215
|
+
new_cron = '\n'.join([line for line in existing_cron.split('\n') if 'fetchtastic download' not in line])
|
|
216
|
+
# Update crontab
|
|
217
|
+
process = subprocess.Popen(['crontab', '-'], stdin=subprocess.PIPE, text=True)
|
|
218
|
+
process.communicate(input=new_cron)
|
|
219
|
+
print("Existing Fetchtastic cron job removed.")
|
|
220
|
+
# Ask if they want to add a new cron job
|
|
221
|
+
add_cron = input("Do you want to add a new crontab entry to run Fetchtastic daily at 3 AM? [y/n] (default: y): ").strip().lower() or 'y'
|
|
222
|
+
if add_cron == 'y':
|
|
223
|
+
# Add new cron job
|
|
224
|
+
new_cron += f"\n0 3 * * * fetchtastic download\n"
|
|
225
|
+
# Update crontab
|
|
226
|
+
process = subprocess.Popen(['crontab', '-'], stdin=subprocess.PIPE, text=True)
|
|
227
|
+
process.communicate(input=new_cron)
|
|
228
|
+
print("New cron job added.")
|
|
229
|
+
else:
|
|
230
|
+
print("Skipping cron job installation.")
|
|
231
|
+
else:
|
|
232
|
+
print("Keeping existing crontab entry.")
|
|
233
|
+
else:
|
|
234
|
+
# No existing fetchtastic cron job
|
|
235
|
+
add_cron = input("Do you want to add a crontab entry to run Fetchtastic daily at 3 AM? [y/n] (default: y): ").strip().lower() or 'y'
|
|
236
|
+
if add_cron == 'y':
|
|
237
|
+
# Add new cron job
|
|
238
|
+
new_cron = existing_cron.strip() + f"\n0 3 * * * fetchtastic download\n"
|
|
239
|
+
# Update crontab
|
|
240
|
+
process = subprocess.Popen(['crontab', '-'], stdin=subprocess.PIPE, text=True)
|
|
241
|
+
process.communicate(input=new_cron)
|
|
242
|
+
print("Cron job added to run Fetchtastic daily at 3 AM.")
|
|
243
|
+
else:
|
|
244
|
+
print("Skipping cron job installation.")
|
|
245
|
+
except Exception as e:
|
|
246
|
+
print(f"An error occurred while setting up the cron job: {e}")
|
|
247
|
+
|
|
248
|
+
def run_clean():
|
|
249
|
+
print("This will remove Fetchtastic configuration files, downloaded files, and cron job entries.")
|
|
250
|
+
confirm = input("Are you sure you want to proceed? [y/n] (default: n): ").strip().lower() or 'n'
|
|
251
|
+
if confirm != 'y':
|
|
252
|
+
print("Clean operation cancelled.")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Remove configuration file
|
|
256
|
+
if os.path.exists(CONFIG_FILE):
|
|
257
|
+
os.remove(CONFIG_FILE)
|
|
258
|
+
print(f"Removed configuration file: {CONFIG_FILE}")
|
|
259
|
+
|
|
260
|
+
# Remove download directory
|
|
261
|
+
if os.path.exists(DEFAULT_CONFIG_DIR):
|
|
262
|
+
shutil.rmtree(DEFAULT_CONFIG_DIR)
|
|
263
|
+
print(f"Removed download directory: {DEFAULT_CONFIG_DIR}")
|
|
264
|
+
|
|
265
|
+
# Remove cron job entries
|
|
266
|
+
try:
|
|
267
|
+
# Get current crontab entries
|
|
268
|
+
result = subprocess.run(['crontab', '-l'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
269
|
+
if result.returncode == 0:
|
|
270
|
+
existing_cron = result.stdout
|
|
271
|
+
# Remove existing fetchtastic cron jobs
|
|
272
|
+
new_cron = '\n'.join([line for line in existing_cron.split('\n') if 'fetchtastic download' not in line])
|
|
273
|
+
# Update crontab
|
|
274
|
+
process = subprocess.Popen(['crontab', '-'], stdin=subprocess.PIPE, text=True)
|
|
275
|
+
process.communicate(input=new_cron)
|
|
276
|
+
print("Removed Fetchtastic cron job entries.")
|
|
277
|
+
except Exception as e:
|
|
278
|
+
print(f"An error occurred while removing cron jobs: {e}")
|
|
279
|
+
|
|
280
|
+
print("Fetchtastic has been cleaned from your system.")
|
|
281
|
+
print("If you installed Fetchtastic via pip and wish to uninstall it, run 'pip uninstall fetchtastic'.")
|
|
282
|
+
|
|
283
|
+
def load_config():
|
|
284
|
+
if not config_exists():
|
|
285
|
+
return None
|
|
286
|
+
with open(CONFIG_FILE, 'r') as f:
|
|
287
|
+
config = yaml.safe_load(f)
|
|
288
|
+
return config
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Jeremiah K
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: fetchtastic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Meshtastic Firmware and APK Downloader
|
|
5
|
+
Home-page: https://github.com/jeremiah-k/fetchtastic
|
|
6
|
+
Author: Jeremiah K
|
|
7
|
+
Author-email: jeremiahk@gmx.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: requests
|
|
15
|
+
Requires-Dist: pick
|
|
16
|
+
Requires-Dist: PyYAML
|
|
17
|
+
Requires-Dist: urllib3
|
|
18
|
+
|
|
19
|
+
# Fetchtastic Termux Setup
|
|
20
|
+
|
|
21
|
+
Fetchtastic is a tool to download the latest Meshtastic Android app and Firmware releases to your phone via Termux. It also provides optional notifications via an NTFY server. This guide will help you set up and run Fetchtastic on your device.
|
|
22
|
+
|
|
23
|
+
## Prerequisites
|
|
24
|
+
|
|
25
|
+
### Install Termux and Add-ons
|
|
26
|
+
|
|
27
|
+
1. **Install Termux**: Download and install [Termux](https://f-droid.org/en/packages/com.termux/) from F-Droid.
|
|
28
|
+
2. **Install Termux Boot**: Download and install [Termux Boot](https://f-droid.org/en/packages/com.termux.boot/) from F-Droid.
|
|
29
|
+
3. **Install Termux API**: Download and install [Termux API](https://f-droid.org/en/packages/com.termux.api/) from F-Droid.
|
|
30
|
+
4. *(Optional)* **Install ntfy**: Download and install [ntfy](https://f-droid.org/en/packages/io.heckel.ntfy/) from F-Droid.
|
|
31
|
+
|
|
32
|
+
### Request Storage Access for Termux
|
|
33
|
+
|
|
34
|
+
Open Termux and run the following command to grant storage access:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
termux-setup-storage
|
|
38
|
+
```
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
### Step 1: Install Python
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pkg install python -y
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Step 2: Install Fetchtastic
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install fetchtastic
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
### Run the Setup Process
|
|
56
|
+
|
|
57
|
+
Run the setup command and follow the prompts to configure Fetchtastic:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
fetchtastic setup
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
During setup, you will be able to:
|
|
64
|
+
|
|
65
|
+
- Choose whether to download APKs, firmware, or both.
|
|
66
|
+
- Select specific assets to download.
|
|
67
|
+
- Set the number of versions to keep.
|
|
68
|
+
- Configure automatic extraction of firmware files. (Optional)
|
|
69
|
+
- Set up notifications via NTFY. (Optional)
|
|
70
|
+
- Add a cron job to run Fetchtastic regularly. (Optional)
|
|
71
|
+
|
|
72
|
+
### Perform Downloads
|
|
73
|
+
|
|
74
|
+
To manually start the download process, run:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
fetchtastic download
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
This will download the latest versions of the selected assets and store them in the specified directories.
|
|
81
|
+
|
|
82
|
+
### Command list
|
|
83
|
+
|
|
84
|
+
- **setup**: Run the setup process.
|
|
85
|
+
- **download**: Download firmware and APKs.
|
|
86
|
+
- **topic**: Display the current NTFY topic.
|
|
87
|
+
- **clean**: Remove configuration, downloads, and cron jobs.
|
|
88
|
+
- **--help**: Show help and usage instructions.
|
|
89
|
+
|
|
90
|
+
### Files and Directories
|
|
91
|
+
|
|
92
|
+
By default, Fetchtastic saves files and configuration in the `Downloads/Fetchtastic` directory:
|
|
93
|
+
|
|
94
|
+
- **Configuration File**: `Downloads/Fetchtastic/fetchtastic.yaml`
|
|
95
|
+
- **Log File**: `Downloads/Fetchtastic/fetchtastic.log`
|
|
96
|
+
- **APKs**: `Downloads/Fetchtastic/apks`
|
|
97
|
+
- **Firmware**: `Downloads/Fetchtastic/firmware`
|
|
98
|
+
|
|
99
|
+
You can manually edit the configuration file to change the settings.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
### Scheduling with Cron
|
|
103
|
+
|
|
104
|
+
During setup, you have the option to add a cron job that runs Fetchtastic daily at 3 AM.
|
|
105
|
+
|
|
106
|
+
To modify cron job, you can run:
|
|
107
|
+
```bash
|
|
108
|
+
crontab -e
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Notifications via NTFY
|
|
112
|
+
|
|
113
|
+
If you choose to set up notifications, Fetchtastic will send updates to your specified NTFY topic.
|
|
114
|
+
|
|
115
|
+
### Contributing
|
|
116
|
+
|
|
117
|
+
Contributions are welcome! Feel free to open issues or submit pull requests.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
app/cli.py,sha256=0MXDY-WKgQSg3ndFsAY9EItBfrEabvevT6Z1Ey39R0s,2065
|
|
3
|
+
app/downloader.py,sha256=djXk9zkbkTr407bKDcrT9l-j3yOXRz0KZ_Unc64W6O8,11509
|
|
4
|
+
app/menu_apk.py,sha256=IzMQyvFLp9_LJCbeDK09cKkrnQ5iqaCVZhdvRlpoeyg,1399
|
|
5
|
+
app/menu_firmware.py,sha256=sKfgrpk-NN4mIhmNcklTBR33uHzMae8s3Y4NP-4sl_8,1376
|
|
6
|
+
app/setup_config.py,sha256=QGexpQVuRKZfoh4Dy83TxHMRRico5LsyUeezwqpeBVA,12780
|
|
7
|
+
fetchtastic-0.1.0.dist-info/LICENSE,sha256=spPlGh4STUWH8_Wge3VdIWSjXw8Kiroctn19wOrniMI,1067
|
|
8
|
+
fetchtastic-0.1.0.dist-info/METADATA,sha256=PjjSjfUuSjRQkB7_RVHrzyD9ISTKyWNfGRtj2o--O78,3365
|
|
9
|
+
fetchtastic-0.1.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
10
|
+
fetchtastic-0.1.0.dist-info/entry_points.txt,sha256=U_eZT-XoZI0JWwSus3520FnSoe4ux6F6BsRSJ-nuiZQ,45
|
|
11
|
+
fetchtastic-0.1.0.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
|
|
12
|
+
fetchtastic-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
app
|