Files

236 lines
12 KiB
Python

#!/usr/bin/env python3
"""Publish a single weather post and uploaded charts through Lemmy API v3."""
from datetime import datetime, timezone
import fcntl
import hashlib
import json
import logging
import os
from pathlib import Path
import uuid
from urllib.error import HTTPError
from urllib.parse import quote, urlsplit
from urllib.request import Request, build_opener, HTTPRedirectHandler
from zoneinfo import ZoneInfo
from settings import load as load_config
BASE = Path(__file__).resolve().parent
START = '[meteo-start]: #'
END = '[meteo-end]: #'
LEGACY_START = '<!-- METEO:START -->'
LEGACY_END = '<!-- METEO:END -->'
CHARTS = [('temperatura', 'Temperatura'), ('wilgotnosc', 'Wilgotność powietrza'),
('cisnienie', 'Ciśnienie atmosferyczne')]
def read_json(path):
return json.loads(path.read_text(encoding='utf-8'))
def save_json(path, data):
temporary = path.with_suffix('.tmp')
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
temporary.chmod(0o600)
temporary.replace(path)
class ApiError(Exception):
def __init__(self, status, code):
super().__init__(f'Lemmy HTTP {status}: {code}')
self.status = status
self.code = code
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ApiError(code, 'redirect_refused; configure canonical instance address')
class Client:
def __init__(self):
self.secrets = load_config()['lemmy']
self.base = self.secrets['instance'].rstrip('/')
self.token_path = BASE / 'data/lemmy-token.json'
self.token = None
if self.token_path.exists():
cached = read_json(self.token_path)
if cached.get('instance') == self.base and cached.get('username') == self.secrets['username']:
self.token = cached.get('jwt')
def request(self, path, data=None, method=None, content_type='application/json', auth=True, retry=True):
if auth and not self.token:
self.login()
headers = {'User-Agent': 'MeteoStation/1.0', 'Accept': 'application/json'}
if auth:
headers['Authorization'] = 'Bearer ' + self.token
if data is not None:
headers['Content-Type'] = content_type
if not isinstance(data, bytes):
data = json.dumps(data).encode('utf-8')
request = Request(self.base + path, data=data, headers=headers, method=method)
try:
with build_opener(NoRedirect()).open(request, timeout=45) as response:
return json.load(response)
except HTTPError as exc:
try:
code = json.loads(exc.read()).get('error', 'api_error')
except Exception:
code = 'non_json_error'
if auth and retry and (exc.code == 401 or code in ('not_logged_in', 'incorrect_login')):
self.login()
return self.request(path, data, method, content_type, auth=True, retry=False)
raise ApiError(exc.code, code) from None
def login(self):
result = self.request('/api/v3/user/login', {
'username_or_email': self.secrets['username'], 'password': self.secrets['password']},
method='POST', auth=False)
if not result.get('jwt'):
raise ValueError('Login did not return a token')
self.token = result['jwt']
save_json(self.token_path, {'jwt': self.token, 'instance': self.base, 'username': self.secrets['username']})
def upload(self, filename):
boundary = 'meteo-' + uuid.uuid4().hex
header = (f'--{boundary}\r\nContent-Disposition: form-data; name="images[]"; '
f'filename="{filename.name}"\r\nContent-Type: image/png\r\n\r\n').encode()
body = header + filename.read_bytes() + f'\r\n--{boundary}--\r\n'.encode()
result = self.request('/pictrs/image', body, 'POST', 'multipart/form-data; boundary=' + boundary)
files = result.get('files', [])
if len(files) != 1:
raise ValueError('Image upload did not return exactly one image')
entry = files[0]
return {'url': self.base + '/pictrs/image/' + quote(entry['file'], safe=''),
'file': entry['file'], 'delete_token': entry.get('delete_token')}
def merge_body(existing, section):
start, end = (LEGACY_START, LEGACY_END) if LEGACY_START in existing else (START, END)
if existing.count(start) != 1 or existing.count(end) != 1:
raise ValueError('Weather markers missing or duplicated; post left unchanged')
before, rest = existing.split(start)
middle, after = rest.split(end)
return before + START + '\n\n' + section + '\n\n' + END + after
def render_section(status, images, now=None, config=None):
config = config or load_config()
now = now or datetime.now(timezone.utc)
checked = datetime.fromisoformat(status['checked_at'])
age = (now - checked).total_seconds()
healthy = status['status'] == 'ok' and 0 <= age <= config['stale_after_minutes'] * 60
if config.get('heartbeat', {}).get('enabled', False):
healthy = healthy and status.get('freshness_verified') is True
values = status.get('values') if healthy else status.get('last_success_values')
def value(name, unit):
return ('—' if not values else f"{values[name]:.1f}".replace('.', ',') + ' ' + unit)
# Non-breaking-space paragraphs retain vertical spacing in Lemmy Markdown.
lines = ['## ' + config['lemmy']['heading'], '', '\u00a0', '', '\u00a0', '',
f"**Temperatura:** {value('temperature', '°C')} ",
f"**Wilgotność:** {value('humidity', '%')} ",
f"**Ciśnienie:** {value('pressure', 'hPa')} ",
'**Status:** ' + ('🟢 Działa poprawnie' if healthy else '🔴 Błąd stacji') + ' ']
stamp = status.get('last_success_at')
shown = datetime.fromisoformat(stamp).astimezone(ZoneInfo(config['timezone'])).strftime('%d.%m.%Y, %H:%M') if stamp else 'brak'
lines.append('**Ostatni poprawny pomiar:** ' + shown)
if not healthy and values:
lines.append('\nPodane wartości pochodzą z ostatniego poprawnego pomiaru.')
if not healthy:
lines.append('\nBrak aktualnego, poprawnego odczytu lub potwierdzenia świeżości danych stacji.')
elif not config.get('heartbeat', {}).get('enabled', False):
lines.append('\nKontrola heartbeatu źródła jest wyłączona; status dotyczy odczytu Modbus.')
lines.append('\n' + config['lemmy']['update_note'])
for slug, title in CHARTS:
lines.extend(['', f'### {title}', '', f"![{title} — bieżący miesiąc]({images[slug]['url']})"])
return '\n'.join(lines)
def publish():
os.umask(0o077)
config = load_config()
settings = config['lemmy']
if not settings.get('enabled'):
return None
(BASE / 'data').mkdir(exist_ok=True)
with (BASE / 'data/publish.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
client = Client()
identity = client.request('/api/v3/site')['my_user']['local_user_view']['person']['id']
state_path = BASE / 'data/lemmy-state.json'
state = read_json(state_path) if state_path.exists() else {'images': {}}
destination = {'instance': client.base, 'author_id': identity, 'community_id': settings['community_id']}
if state.get('destination') not in (None, destination):
raise ValueError('Destination changed: archive local publication state before using a different account or community')
if settings['post_id'] and state.get('post_id') and settings['post_id'] != state['post_id']:
raise ValueError('Configured post_id differs from saved state')
state['destination'] = destination
status = read_json(BASE / 'data/status.json')
manifest = read_json(BASE / 'wykresy/manifest.json')
if manifest['month'] != datetime.now(ZoneInfo(config['timezone'])).strftime('%Y-%m'):
raise ValueError('Chart month is not current; publication deferred')
for info in manifest['charts'].values():
if info['last_success_at'] and status['last_success_at'] and datetime.fromisoformat(info['last_success_at']) != datetime.fromisoformat(status['last_success_at']):
raise ValueError('Chart and measurement timestamps differ; publication deferred')
post_id = state.get('post_id') or settings['post_id']
if not post_id:
result = client.request('/api/v3/user?person_id=' + str(identity) + '&limit=50&sort=New')
candidates = [p['post'] for p in result.get('posts', []) if p['post']['community_id'] == settings['community_id']
and p['post']['name'] == settings['title'] and any(marker in (p['post'].get('body') or '') for marker in (START, LEGACY_START))]
if len(candidates) > 1:
raise ValueError('Multiple matching weather posts; manual selection required')
if candidates:
post_id = state['post_id'] = candidates[0]['id']
state.pop('creation_pending', None)
save_json(state_path, state)
elif state.get('creation_pending'):
raise ValueError('Previous creation outcome uncertain; refusing duplicate post')
if post_id:
current = client.request('/api/v3/post?id=' + str(post_id))['post_view']['post']
if current['creator_id'] != identity or current['community_id'] != settings['community_id']:
raise ValueError('Post ownership or community mismatch')
if current['deleted'] or current['removed']:
raise ValueError('Post has been deleted or removed')
merge_body(current.get('body') or '', '') # Validate markers before uploading.
for slug, title in CHARTS:
path = BASE / 'wykresy' / (slug + '.png')
digest = hashlib.sha256(path.read_bytes()).hexdigest()
if state['images'].get(slug, {}).get('sha256') != digest:
uploaded = client.upload(path)
uploaded['sha256'] = digest
with (BASE / 'data/lemmy-uploads.jsonl').open('a', encoding='utf-8') as archive:
archive.write(json.dumps({'uploaded_at': datetime.now(timezone.utc).isoformat(),
'chart': slug, **uploaded}) + '\n')
state['images'][slug] = uploaded
save_json(state_path, state)
section = render_section(status, state['images'], config=config)
if not post_id:
state['creation_pending'] = True
save_json(state_path, state)
appendix = (BASE / 'static.txt').read_text(encoding='utf-8') if (BASE / 'static.txt').exists() else ''
result = client.request('/api/v3/post', {'name': settings['title'], 'community_id': settings['community_id'],
'body': START + '\n\n' + section + '\n\n' + END + '\n\n' + appendix, 'nsfw': False}, 'POST')
post_id = state['post_id'] = result['post_view']['post']['id']
state.pop('creation_pending', None)
save_json(state_path, state)
else:
# Fetch again to preserve edits made while uploading images.
current = client.request('/api/v3/post?id=' + str(post_id))['post_view']['post']
body = merge_body(current.get('body') or '', section)
if body != current.get('body'):
client.request('/api/v3/post', {'post_id': post_id, 'body': body}, 'PUT')
post = client.request('/api/v3/post?id=' + str(post_id))['post_view']['post']
if settings.get('pin_in_community') and not post['featured_community']:
client.request('/api/v3/post/feature', {'post_id': post_id, 'featured': True, 'feature_type': 'Community'}, 'POST')
state['post_id'] = post_id
state['last_published_at'] = datetime.now(timezone.utc).isoformat(timespec='seconds')
state['last_measurement_at'] = status['checked_at']
save_json(state_path, state)
url = client.base + '/post/' + str(post_id)
logging.info('Zaktualizowano wpis Lemmy: %s', url)
return url
if __name__ == '__main__':
print(publish())