Skip to content

Commit f8f0ffe

Browse files
authored
Merge pull request #249 from vibec0re/feat/places-changed-hook-235
feat(places): fire a place-changed hook on presence transitions (#235)
2 parents 30fb079 + bc5a370 commit f8f0ffe

2 files changed

Lines changed: 151 additions & 3 deletions

File tree

crates/hytte-services/src/places.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222
//! [`current`] (for weather: place coords + name when matched, else the raw
2323
//! `GeoClue` passthrough), each with a cross-thread shared handle. Requires
2424
//! `wifiscan::service()` and `geoclue::service()` registered first.
25+
//!
26+
//! Also fires the `place-changed` hook (see [`crate::hooks`]) on a genuine
27+
//! place *transition* — deduped on the place name, not the raw resolution
28+
//! (`GeoClue` re-resolves jitter coordinates without changing the matched
29+
//! place). The very first resolution after startup is recorded but never
30+
//! fires, so login stays quiet.
2531
2632
use std::collections::HashSet;
2733
use std::path::{Path, PathBuf};
@@ -33,6 +39,7 @@ use hytte_reactive::{Service, registry};
3339
use tokio::sync::Notify;
3440

3541
use crate::geoclue::{self, LocationSnapshot, LocationSource, LocationState};
42+
use crate::hooks;
3643
use crate::wifiscan::{self, AccessPoint};
3744

3845
// ── Config ────────────────────────────────────────────────────────────────--
@@ -63,6 +70,13 @@ const DEFAULT_CONFIG: &str = r#"# trollshell places — where you frequent, how
6370
# used as home.
6471
#
6572
# Station ids: https://v6.bvg.transport.rest/locations?query=Schöneweide
73+
#
74+
# Moving between named places fires the `place-changed` hook — drop a script
75+
# at ~/.config/trollshell/hooks/place-changed and it runs with
76+
# $TROLLSHELL_PLACE (the place name) and $TROLLSHELL_PLACE_STATION (its
77+
# station id, empty when unset). Transitions are deduped by name, and the
78+
# very first resolution after login/startup never fires — only actual
79+
# changes do. See docs/superpowers/specs/2026-05-05-settings-hooks-design.md.
6680
6781
[[place]]
6882
name = "Schöneweide"
@@ -549,6 +563,33 @@ async fn watch_config(places: Mutable<Arc<Vec<Place>>>) {
549563
}
550564
}
551565

566+
/// Decide whether a freshly resolved `place` should fire the `place-changed`
567+
/// hook, given whether we've resolved at least once before (`resolved_once`)
568+
/// and the name last fired for (`last_fired`). Both are updated in place.
569+
///
570+
/// Dedups on the place **name** (identity), not the full [`ResolvedPlace`] —
571+
/// `GeoClue` re-resolves emit a fresh struct (coordinates move) on every
572+
/// jitter even when the matched place hasn't changed, which would otherwise
573+
/// spam the hook on every sensor tick (#235). The very first resolution
574+
/// (e.g. at login) seeds the bookkeeping but never fires, so a freshly
575+
/// started shell stays quiet until an actual transition happens.
576+
///
577+
/// Returns the place to fire the hook for on a genuine transition, else
578+
/// `None` (first resolution, no change, or the transition is *into* "no
579+
/// place" — there's no name to report).
580+
fn place_transition<'a>(
581+
place: Option<&'a ResolvedPlace>,
582+
resolved_once: &mut bool,
583+
last_fired: &mut Option<String>,
584+
) -> Option<&'a ResolvedPlace> {
585+
let name = place.map(|p| p.name.clone());
586+
let is_first = !*resolved_once;
587+
*resolved_once = true;
588+
let transitioned = !is_first && name != *last_fired;
589+
*last_fired = name;
590+
if transitioned { place } else { None }
591+
}
592+
552593
async fn resolve_loop(
553594
place_out: Mutable<Option<ResolvedPlace>>,
554595
location_out: Mutable<LocationState>,
@@ -600,6 +641,9 @@ async fn resolve_loop(
600641
});
601642
}
602643

644+
let mut place_hook_resolved_once = false;
645+
let mut place_hook_last_fired: Option<String> = None;
646+
603647
loop {
604648
let current = places.get_cloned();
605649
let ap_list = match &aps {
@@ -612,6 +656,23 @@ async fn resolve_loop(
612656
};
613657
let (place, location) = resolve(&current, &ap_list, &geoloc);
614658

659+
if let Some(p) = place_transition(
660+
place.as_ref(),
661+
&mut place_hook_resolved_once,
662+
&mut place_hook_last_fired,
663+
) {
664+
hooks::run(
665+
"place-changed",
666+
&[
667+
("TROLLSHELL_PLACE", p.name.as_str()),
668+
(
669+
"TROLLSHELL_PLACE_STATION",
670+
p.station.as_deref().unwrap_or(""),
671+
),
672+
],
673+
);
674+
}
675+
615676
if place_out.get_cloned() != place {
616677
if let Some(p) = &place {
617678
tracing::debug!(place = %p.name, station = ?p.station, "places: resolved");
@@ -812,6 +873,89 @@ mod tests {
812873
}
813874
}
814875

876+
// ── place-changed hook dedup ────────────────────────────────────────────
877+
878+
fn resolved(name: &str, station: Option<&str>) -> ResolvedPlace {
879+
ResolvedPlace {
880+
name: name.to_string(),
881+
lat: 0.0,
882+
lon: 0.0,
883+
station: station.map(str::to_string),
884+
walk_minutes: 0,
885+
lines: Vec::new(),
886+
directions: Vec::new(),
887+
}
888+
}
889+
890+
#[test]
891+
fn place_transition_silent_on_first_resolution() {
892+
let mut resolved_once = false;
893+
let mut last_fired = None;
894+
let home = Some(resolved("Home", Some("900180001")));
895+
896+
assert!(place_transition(home.as_ref(), &mut resolved_once, &mut last_fired).is_none());
897+
assert!(resolved_once);
898+
assert_eq!(last_fired.as_deref(), Some("Home"));
899+
}
900+
901+
#[test]
902+
fn place_transition_dedups_on_name_not_full_struct() {
903+
// Same name, different coords each call (GeoClue jitter) — must not
904+
// re-fire after the first (silent) resolution.
905+
let mut resolved_once = false;
906+
let mut last_fired = None;
907+
let away_1 = Some(ResolvedPlace {
908+
lat: 52.1,
909+
..resolved("Nearby", None)
910+
});
911+
let away_2 = Some(ResolvedPlace {
912+
lat: 52.2,
913+
..resolved("Nearby", None)
914+
});
915+
916+
assert!(place_transition(away_1.as_ref(), &mut resolved_once, &mut last_fired).is_none()); // first: silent
917+
assert!(place_transition(away_2.as_ref(), &mut resolved_once, &mut last_fired).is_none()); // same name: no fire
918+
assert!(place_transition(away_1.as_ref(), &mut resolved_once, &mut last_fired).is_none()); // still no fire
919+
}
920+
921+
#[test]
922+
fn place_transition_fires_on_genuine_name_change() {
923+
let mut resolved_once = false;
924+
let mut last_fired = None;
925+
let home = Some(resolved("Home", Some("900180001")));
926+
let office = Some(resolved("Office", Some("900008888")));
927+
928+
assert!(place_transition(home.as_ref(), &mut resolved_once, &mut last_fired).is_none()); // first: silent
929+
let fired = place_transition(office.as_ref(), &mut resolved_once, &mut last_fired)
930+
.expect("name changed → fires");
931+
assert_eq!(fired.name, "Office");
932+
assert_eq!(fired.station.as_deref(), Some("900008888"));
933+
assert_eq!(last_fired.as_deref(), Some("Office"));
934+
935+
// Back to Home is itself a transition.
936+
let fired_again = place_transition(home.as_ref(), &mut resolved_once, &mut last_fired)
937+
.expect("transition back also fires");
938+
assert_eq!(fired_again.name, "Home");
939+
}
940+
941+
#[test]
942+
fn place_transition_into_no_place_updates_state_but_reports_nothing() {
943+
let mut resolved_once = false;
944+
let mut last_fired = None;
945+
let home = Some(resolved("Home", None));
946+
let none: Option<ResolvedPlace> = None;
947+
948+
assert!(place_transition(home.as_ref(), &mut resolved_once, &mut last_fired).is_none()); // first: silent
949+
// Transitioning to "no place" has no name to report, even though it's
950+
// a real transition in the bookkeeping.
951+
assert!(place_transition(none.as_ref(), &mut resolved_once, &mut last_fired).is_none());
952+
assert_eq!(last_fired, None);
953+
// Coming back to Home is a transition again (last_fired was cleared).
954+
let fired = place_transition(home.as_ref(), &mut resolved_once, &mut last_fired)
955+
.expect("re-arriving fires again");
956+
assert_eq!(fired.name, "Home");
957+
}
958+
815959
// ── Live reload ──────────────────────────────────────────────────────────
816960

817961
#[test]

docs/superpowers/specs/2026-05-05-settings-hooks-design.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@
1313

1414
- Hook scripts live at `$HOME/.config/trollshell/hooks/<event>` — one file per event, no `.d/` directory, no extension required.
1515
- The file must be a regular file with the executable bit set (`mode & 0o111 != 0`). It is invoked directly (no `sh -c` wrapping); the user's shebang decides the interpreter.
16-
- v1 fires exactly one event: `theme-changed`. Future events (`network-up`, `power-state`, etc.) drop into the same directory under different names.
16+
- v1 shipped one event, `theme-changed`; more slot into the same directory under different names as callers are added (the API itself is unchanged). Fired events so far:
17+
- `theme-changed` (`theme::set`) — the desktop theme flipped.
18+
- `place-changed` (`places::resolve_loop`) — the resolved current place transitioned (Wi-Fi fingerprint / GeoClue presence). Deduped on the place **name**, and the first resolution after startup is silent, so login stays quiet and GeoClue jitter within one place doesn't re-fire (#235).
1719
- Inputs are passed as environment variables, never as positional args:
1820
- `TROLLSHELL_EVENT=<event-name>` — always present
19-
- Event-specific vars set by the caller. For `theme-changed`: `TROLLSHELL_THEME=light` or `TROLLSHELL_THEME=dark`.
21+
- Event-specific vars set by the caller:
22+
- `theme-changed`: `TROLLSHELL_THEME=light` or `TROLLSHELL_THEME=dark`.
23+
- `place-changed`: `TROLLSHELL_PLACE=<place name>` and `TROLLSHELL_PLACE_STATION=<station id>` (empty when the place has no configured station, e.g. "away").
2024
- `$HOME` resolution mirrors `theme.rs::config_subdir`: `$HOME/.config/...` directly, no `$XDG_CONFIG_HOME`. If both files later need XDG support, both get upgraded together.
2125

2226
### `hooks::run` API
@@ -112,4 +116,4 @@ No integration test for `theme::set` → `hooks::run`. The wire-up is a single l
112116
- Positional args / arg-parsing.
113117
- OSD or settings-panel surfacing of hook failures.
114118
- `$XDG_CONFIG_HOME` resolution (would be a one-shot upgrade across `theme.rs` and `hooks.rs` together).
115-
- Other events: `network-up`, `power-state`, `lock`, etc. The contract is designed so they slot in by adding a new caller; `hooks::run` itself does not need to change.
119+
- Further events: `network-up`, `power-state`, `lock`, etc. The contract is designed so they slot in by adding a new caller; `hooks::run` itself does not need to change. (`place-changed` was the first such addition — see the event list above.)

0 commit comments

Comments
 (0)