diff --git a/work/meshai/adapter_config/defaults.py b/work/meshai/adapter_config/defaults.py index 7a9d62a..3585b42 100644 --- a/work/meshai/adapter_config/defaults.py +++ b/work/meshai/adapter_config/defaults.py @@ -586,6 +586,11 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = { "type": "int", "description": "Maximum characters for the area field on line 2 of NWS wire. Truncates at last word boundary.", }, + ("nws", "single_packet_max_chars"): { + "default": 200, + "type": "int", + "description": "Maximum characters for a single NWS mesh packet. Budget enforced after all fields are assembled; only the locations town-list is trimmed to fit.", + }, # ================================================================= # AVALANCHE -- 1 setting (min danger level broadcast floor) diff --git a/work/meshai/central/consumer.py b/work/meshai/central/consumer.py index 703a6fe..1ccc150 100644 --- a/work/meshai/central/consumer.py +++ b/work/meshai/central/consumer.py @@ -608,7 +608,7 @@ class CentralConsumer: data["_meshai_precomposed"] = True kwargs = dict( - title=str(title)[:200], + title=str(title) if data.get("_meshai_precomposed") else str(title)[:200], summary="", lat=lat, lon=lon, diff --git a/work/meshai/central/nws_handler.py b/work/meshai/central/nws_handler.py index b28c853..7db3e63 100644 --- a/work/meshai/central/nws_handler.py +++ b/work/meshai/central/nws_handler.py @@ -104,7 +104,9 @@ def _parse_nws_description(description: str) -> dict: if m: text = m.group(1).replace("\n", " ").strip() if text: - result[key] = text[:80] + # Preserve the FULL town list for path-sampling in _render(); + # all other fields keep the 80-char cap. + result[key] = text[:400] if key == "locations" else text[:80] return result @@ -401,27 +403,57 @@ def _render(*, event_type, area_desc, geocoder_city, county, state, cert_seg = f" | {certainty}" line3 = f"{hazard_text}{cert_seg}" if hazard_text else "" - # Line 4: motion + locations + # Line 4: motion + locations (path-sampled if the full town list won't fit). compass, speed_mph = _parse_motion(params) motion = f"Moving {compass} {speed_mph} mph" if compass and speed_mph else "" - locations = (desc.get("locations") or "").rstrip("., ") - _loc_limit = int(adapter_config.nws.locations_max_chars) - if len(locations) > _loc_limit: - cut = locations[:_loc_limit].rsplit(" ", 1)[0] - if not cut: - cut = locations[:_loc_limit] - locations = cut + "\u2026" - if motion and locations: - line4 = f"{motion} — {locations}" - elif motion: - line4 = motion - elif locations: - line4 = locations - else: - line4 = "" - lines = [l for l in (line1, line2, line3, line4) if l] - return "\n".join(lines) + # Parse the (now-full) locations string into an ordered town list. Path + # order = soonest-impact first ... farthest-along last. The tail element + # frequently starts with "and " (e.g. "and Shoshone") -> strip that. + raw = (desc.get("locations") or "").rstrip("., ") + towns = [t.strip() for t in raw.split(",") if t.strip()] + if towns: + towns[-1] = re.sub(r"^and\s+", "", towns[-1], flags=re.IGNORECASE).strip() + towns = [t for t in towns if t] + + def _line4(locs: str) -> str: + if motion and locs: + return f"{motion} — {locs}" + if motion: + return motion + return locs or "" + + PACKET_LIMIT = int(getattr(adapter_config.nws, "single_packet_max_chars", 200)) + + # Prefer the FULL town list; use it verbatim if the whole message fits. + full_locs = ", ".join(towns) + line4 = _line4(full_locs) + msg = "\n".join(l for l in (line1, line2, line3, line4) if l) + + if len(msg) > PACKET_LIMIT: + # Won't fit: collapse locations to a path sample that never loses the + # endpoints -> soonest-impact -> midway -> farthest-along. + if len(towns) >= 3: + sampled = [towns[0], towns[len(towns) // 2], towns[-1]] + elif len(towns) == 2: + sampled = [towns[0], towns[-1]] + else: + sampled = list(towns) + # De-dup consecutive repeats (a short list can make first == middle), + # so we never render "Buhl → Buhl → Shoshone". + deduped = [] + for t in sampled: + if not deduped or deduped[-1] != t: + deduped.append(t) + path = " → ".join(deduped) + line4 = _line4(path) + msg = "\n".join(l for l in (line1, line2, line3, line4) if l) + + # Final hard-cap safety net for the pathological case (extremely long town + # names overflow even the first->middle->last sample plus the other lines). + if len(msg) > PACKET_LIMIT: + msg = msg[:PACKET_LIMIT - 1].rstrip() + "…" + return msg def _category_to_event_type(category_raw: str) -> str: diff --git a/work/tests/test_nws_handler.py b/work/tests/test_nws_handler.py index 465f31c..456dc2a 100644 --- a/work/tests/test_nws_handler.py +++ b/work/tests/test_nws_handler.py @@ -1,7 +1,7 @@ """Tests for v0.5.10 NWS handler.""" import pytest -from meshai.central.nws_handler import handle_nws, _emoji_for_event +from meshai.central.nws_handler import handle_nws, _emoji_for_event, _render from meshai.persistence import close_thread_connection, init_db from meshai.persistence import db as persistence_db @@ -239,3 +239,98 @@ def test_non_warning_category_no_severity_override(mem_db): wire = handle_nws(env, env["subject"], data=data, now=3_000_000) assert wire is not None assert "_severity_override" not in data + + +# ---- packet-budget enforcement ---- + + +def test_svr_long_locations_path_sampled(mem_db): + """SVR with a long town list: render must fit in 200 chars, and the town + list must be represented as a PATH SAMPLE (first -> middle -> last) rather + than a tail-drop. The old bug dropped the final town ('Shoshone').""" + # Long list; first town "Buhl", last town "and Shoshone" (exercises the + # leading-"and " strip on the tail element). + long_locations = ( + "Buhl, Eden, Hazelton, Murtaugh, Richfield, Dietrich, " + "Gooding, Hagerman, Wendell, and Shoshone" + ) + description = ( + "HAZARD...Damaging winds to 60 mph and quarter-size hail.\n\n" + f"Locations impacted include...{long_locations}" + ) + d = { + "eventCode": {"SAME": ["SVR"]}, + "certainty": "Observed", + "parameters": { + "maxWindGust": ["60 MPH"], + "maxHailSize": ["1.00"], + # 254 DEG 35 KT -> "Moving W 40 mph" + "eventMotionDescription": ["2200000T254DEG...35KT 42.5,-114.5"], + }, + "description": description, + } + rendered = _render( + event_type="Severe Thunderstorm Warning", + area_desc="Twin Falls County", + geocoder_city=None, + county="Twin Falls", + state="ID", + expires_epoch=1_751_400_000, + lat=42.5, + lon=-114.46, + now=1_751_400_000, + d=d, + ) + + # (a) fits in one mesh packet + assert len(rendered) <= 200, ( + f"rendered is {len(rendered)} chars (expected <= 200):\n{rendered!r}" + ) + + # (b) all data-point categories present + assert "Severe Thunderstorm Warning" in rendered, "event type missing" + assert "Until" in rendered, "expiry time segment missing" + assert "Twin Falls County" in rendered, "area missing" + assert "mph winds" in rendered or "hail" in rendered, "hazard segment missing" + assert "Moving" in rendered, "motion segment missing" + + # (c) path-sampling, not tail-drop: first town, last town, and the arrow + assert "→" in rendered, "no arrow -> not path-sampled" + assert "Buhl" in rendered, "first town missing" + assert "Shoshone" in rendered, "last town dropped (the old tail-trim bug)" + + +def test_svr_short_locations_shown_in_full(mem_db): + """Short town list that fits in one packet: show the FULL comma-joined + list, and never emit the path-sample arrow.""" + short_locations = "Buhl, Eden, and Hazelton" + description = ( + "HAZARD...Damaging winds to 60 mph and quarter-size hail.\n\n" + f"Locations impacted include...{short_locations}" + ) + d = { + "eventCode": {"SAME": ["SVR"]}, + "certainty": "Observed", + "parameters": { + "maxWindGust": ["60 MPH"], + "maxHailSize": ["1.00"], + "eventMotionDescription": ["2200000T254DEG...35KT 42.5,-114.5"], + }, + "description": description, + } + rendered = _render( + event_type="Severe Thunderstorm Warning", + area_desc="Twin Falls County", + geocoder_city=None, + county="Twin Falls", + state="ID", + expires_epoch=1_751_400_000, + lat=42.5, + lon=-114.46, + now=1_751_400_000, + d=d, + ) + + assert len(rendered) <= 200 + assert "→" not in rendered, "short list should not be path-sampled" + assert "Buhl, Eden, Hazelton" in rendered, "full comma-joined list expected"