Skip to content

Commit 2157a53

Browse files
committed
Shore up status indicator and notification logic
1 parent dd6af5e commit 2157a53

File tree

6 files changed

+528
-48
lines changed

6 files changed

+528
-48
lines changed

api/check_status.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -263,41 +263,64 @@ def check_status(should_write_cache=False, interval_seconds=None):
263263
else:
264264
print(f" Badge cache failed")
265265

266-
# Notify all channels — done early so notifications aren't lost to timeouts.
267-
# Determines whether to notify due to a new transition or a missed previous notification.
268-
should_notify = False
266+
# Notify channels — done early so notifications aren't lost to timeouts.
267+
# Per-channel tracking: each channel independently tracks its last notified status.
269268
current_reported = reported_status['status']
270269
previous_reported = None
271270

271+
# Backward compatibility: migrate string to per-channel dict
272+
if isinstance(previous_last_notified, str):
273+
from lib.notifiers import ALL_CHANNELS
274+
previous_last_notified = {ch: previous_last_notified for ch in ALL_CHANNELS}
275+
elif previous_last_notified is None:
276+
previous_last_notified = {}
277+
278+
notified_status = dict(previous_last_notified)
279+
notify_channels = None # None means all channels
280+
272281
if hysteresis_result['status_changed'] and previous_reported_status is not None:
273-
should_notify = True
282+
# Full transition — dispatch to ALL channels
274283
previous_reported = previous_reported_status['status']
275284
print(f"\nReported status changed: {previous_reported} -> {current_reported}")
276-
elif previous_last_notified is not None and previous_last_notified != current_reported:
277-
should_notify = True
278-
previous_reported = previous_last_notified
279-
print(f"\nRecovering missed notification: {previous_reported} -> {current_reported}")
285+
else:
286+
# Recovery — only dispatch to channels that are tracked but stale.
287+
# Channels absent from the dict are not tracked (unconfigured).
288+
stale_channels = set()
289+
from lib.notifiers import ALL_CHANNELS
290+
for ch in ALL_CHANNELS:
291+
if ch in notified_status and notified_status[ch] != current_reported:
292+
stale_channels.add(ch)
293+
if stale_channels:
294+
previous_reported = notified_status.get(next(iter(stale_channels)))
295+
print(f"\nRecovering missed notification for channels: {sorted(stale_channels)}")
296+
notify_channels = stale_channels
297+
298+
should_notify = (hysteresis_result['status_changed'] and previous_reported_status is not None) or \
299+
(notify_channels is not None and len(notify_channels) > 0)
280300

281301
if should_notify:
282302
delay_summaries = reported_status.get('detection', {}).get('delay_summaries', [])
283303
notify_results = notify_status_change(
284304
status=current_reported,
285305
previous_status=previous_reported,
286306
delay_summaries=delay_summaries,
287-
timestamp=reported_status['timestamp']
307+
timestamp=reported_status['timestamp'],
308+
channels=notify_channels
288309
)
289-
any_failed = False
290310
for channel, notify_result in notify_results.items():
291311
if notify_result['success']:
292312
print(f" {channel}: OK")
313+
notified_status[channel] = current_reported
293314
elif notify_result.get('skipped'):
294-
pass # Unconfigured channels are not failures
315+
# Unconfigured channels: remove from tracking so they
316+
# don't appear stale and trigger infinite retries
317+
notified_status.pop(channel, None)
295318
else:
296319
print(f" {channel}: Failed - {notify_result.get('error', 'Unknown error')}")
297-
any_failed = True
298-
if not any_failed:
299-
cache_data['last_notified_status'] = current_reported
300-
write_cache(cache_data)
320+
# Leave unchanged — will retry next cycle
321+
322+
cache_data['last_notified_status'] = notified_status
323+
write_cache(cache_data)
301324

302325
# Archive image for debugging/auditing (cloud only, best-effort)
303326
archive_reasons = []

lib/muni_lib.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import os
1313
import requests
1414
import json
15+
import tempfile
1516
from datetime import datetime
1617
from pathlib import Path
1718
from PIL import Image
@@ -131,10 +132,20 @@ def write_cache(data):
131132
)
132133
return True
133134
else:
134-
# Write to local file
135-
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
136-
with open(cache_path, 'w') as f:
137-
json.dump(data, f, indent=2)
135+
# Write to local file atomically via temp file + os.replace()
136+
cache_dir = os.path.dirname(cache_path)
137+
os.makedirs(cache_dir, exist_ok=True)
138+
fd, tmp_path = tempfile.mkstemp(dir=cache_dir, prefix='.cache_tmp_', suffix='.json')
139+
try:
140+
with os.fdopen(fd, 'w') as f:
141+
json.dump(data, f, indent=2)
142+
os.replace(tmp_path, cache_path) # atomic on POSIX
143+
except:
144+
try:
145+
os.unlink(tmp_path)
146+
except OSError:
147+
pass
148+
raise
138149
return True
139150
except Exception as e:
140151
print(f"Error writing cache: {e}")

lib/notifiers/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,16 @@
1717
)
1818
"""
1919

20-
from .dispatcher import notify_status_change
20+
from .dispatcher import notify_status_change, VALID_STATUSES, ALL_CHANNELS
2121
from .bluesky import post_to_bluesky
2222
from .mastodon import post_to_mastodon
2323
from .rss import update_rss_feed, read_rss_feed, generate_empty_feed
2424
from .webhooks import send_webhooks
2525

2626
__all__ = [
2727
'notify_status_change',
28+
'VALID_STATUSES',
29+
'ALL_CHANNELS',
2830
'post_to_bluesky',
2931
'post_to_mastodon',
3032
'update_rss_feed',

lib/notifiers/dispatcher.py

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@
88
from . import bluesky, mastodon, rss, webhooks
99
from .messages import STATUS_MESSAGES
1010

11+
VALID_STATUSES = {'green', 'yellow', 'red'}
12+
ALL_CHANNELS = {'bluesky', 'mastodon', 'rss', 'webhooks'}
1113

12-
def notify_status_change(status, previous_status, delay_summaries=None, timestamp=None):
14+
15+
def notify_status_change(status, previous_status, delay_summaries=None,
16+
timestamp=None, channels=None):
1317
"""
1418
Dispatch status change notification to all enabled channels.
1519
@@ -18,6 +22,9 @@ def notify_status_change(status, previous_status, delay_summaries=None, timestam
1822
previous_status: Previous status for context
1923
delay_summaries: List of delay summary strings (optional)
2024
timestamp: ISO timestamp string (optional)
25+
channels: Set of channel names to dispatch to (optional).
26+
When None, dispatches to all channels. When provided,
27+
only dispatches to channels in the intersection with ALL_CHANNELS.
2128
2229
Returns:
2330
dict: Results from each notifier channel
@@ -28,11 +35,20 @@ def notify_status_change(status, previous_status, delay_summaries=None, timestam
2835
'webhooks': {'success': bool, 'skipped': bool, 'sent': int, 'failed': int, 'error': str}
2936
}
3037
'skipped' is True when the channel is not configured (no credentials/URLs set).
38+
39+
Raises:
40+
ValueError: If status is not one of VALID_STATUSES
3141
"""
42+
if not isinstance(status, str) or status not in VALID_STATUSES:
43+
raise ValueError(f"Invalid status {status!r}; must be one of {sorted(VALID_STATUSES)}")
44+
45+
dispatch_channels = ALL_CHANNELS if channels is None else channels & ALL_CHANNELS
3246
results = {}
3347

3448
# Bluesky (if credentials configured)
35-
if os.getenv('BLUESKY_HANDLE') and os.getenv('BLUESKY_APP_PASSWORD'):
49+
if 'bluesky' not in dispatch_channels:
50+
pass
51+
elif os.getenv('BLUESKY_HANDLE') and os.getenv('BLUESKY_APP_PASSWORD'):
3652
results['bluesky'] = bluesky.post_to_bluesky(
3753
status=status,
3854
previous_status=previous_status,
@@ -47,7 +63,9 @@ def notify_status_change(status, previous_status, delay_summaries=None, timestam
4763
}
4864

4965
# Mastodon (if credentials configured)
50-
if os.getenv('MASTODON_INSTANCE') and os.getenv('MASTODON_ACCESS_TOKEN'):
66+
if 'mastodon' not in dispatch_channels:
67+
pass
68+
elif os.getenv('MASTODON_INSTANCE') and os.getenv('MASTODON_ACCESS_TOKEN'):
5169
results['mastodon'] = mastodon.post_to_mastodon(
5270
status=status,
5371
previous_status=previous_status,
@@ -62,21 +80,25 @@ def notify_status_change(status, previous_status, delay_summaries=None, timestam
6280
}
6381

6482
# RSS feed (always enabled - no credentials needed)
65-
# Use same status message as Bluesky/Mastodon
66-
description = STATUS_MESSAGES.get(status, f'Status: {status}')
67-
results['rss'] = rss.update_rss_feed(
68-
status=status,
69-
description=description,
70-
delay_summaries=delay_summaries,
71-
timestamp=timestamp
72-
)
83+
if 'rss' in dispatch_channels:
84+
# Use same status message as Bluesky/Mastodon
85+
description = STATUS_MESSAGES.get(status, f'Status: {status}')
86+
results['rss'] = rss.update_rss_feed(
87+
status=status,
88+
description=description,
89+
delay_summaries=delay_summaries,
90+
timestamp=timestamp
91+
)
7392

7493
# Webhooks (if URLs configured)
75-
results['webhooks'] = webhooks.send_webhooks(
76-
status=status,
77-
previous_status=previous_status,
78-
delay_summaries=delay_summaries,
79-
timestamp=timestamp
80-
)
94+
if 'webhooks' not in dispatch_channels:
95+
pass
96+
else:
97+
results['webhooks'] = webhooks.send_webhooks(
98+
status=status,
99+
previous_status=previous_status,
100+
delay_summaries=delay_summaries,
101+
timestamp=timestamp
102+
)
81103

82104
return results

0 commit comments

Comments
 (0)