crazycrazypete commited on
Commit
fb5369f
·
verified ·
1 Parent(s): c8981b2

Upload folder using huggingface_hub

Browse files
Updates/app_old.py CHANGED
@@ -3,9 +3,10 @@ import re
3
  import json
4
  import math
5
  import hashlib
 
6
  from dataclasses import dataclass
7
  from datetime import datetime, date
8
- from typing import Dict, List, Optional, Tuple, Any
9
 
10
  import numpy as np
11
  import pandas as pd
@@ -25,15 +26,12 @@ from openai import OpenAI
25
  TODAY = date(2026, 1, 18)
26
  OPENAI_MODEL = "gpt-5.2"
27
  OPENAI_REASONING = {"effort": "high"}
28
-
29
  MATCH_OK = 80
 
30
  EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
31
  PARSEC_CONTEXT_BEFORE = 900
32
  PARSEC_CONTEXT_AFTER = 1600
33
 
34
- CACHE_DIR = os.path.join(os.getcwd(), ".onlyrouters_cache")
35
- os.makedirs(CACHE_DIR, exist_ok=True)
36
-
37
 
38
  # ============================
39
  # OpenAI client (HF Space secret: OPENAI_API_KEY)
@@ -41,9 +39,28 @@ os.makedirs(CACHE_DIR, exist_ok=True)
41
  API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
42
  client = OpenAI(api_key=API_KEY) if API_KEY else None
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  # ============================
46
- # Utilities
47
  # ============================
48
  def norm_text(s: Any) -> str:
49
  try:
@@ -56,38 +73,35 @@ def norm_text(s: Any) -> str:
56
  s = re.sub(r"\s+", " ", s).strip()
57
  return s
58
 
59
- def _safe_str(v: Any) -> str:
60
  if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):
61
  return ""
62
  return str(v).strip()
63
 
64
- def _is_5g(modem_type: Any) -> bool:
65
  s = norm_text(modem_type)
66
  return ("5g" in s) or ("nr" in s)
67
 
68
- def _json_load_safe(s: str) -> Dict[str, Any]:
69
  try:
70
  return json.loads(s)
71
  except Exception:
72
  return {}
73
 
74
- def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 700) -> Dict[str, Any]:
75
  if client is None:
76
  return {}
77
  resp = client.responses.create(
78
  model=OPENAI_MODEL,
79
  reasoning=OPENAI_REASONING,
80
- input=[
81
- {"role": "system", "content": system},
82
- {"role": "user", "content": json.dumps(payload)},
83
- ],
84
  max_output_tokens=max_tokens,
85
  )
86
- return _json_load_safe(getattr(resp, "output_text", "") or "")
87
 
88
 
89
  # ============================
90
- # Load data files (must exist in repo)
91
  # ============================
92
  EOS_PATH = "routers_eos_eol_by_sku.csv"
93
  DEC_PATH = "dec2025routers.csv"
@@ -103,8 +117,7 @@ if not os.path.exists(PARSEC_PDF):
103
  df_eos = pd.read_csv(EOS_PATH).copy()
104
  df_dec = pd.read_csv(DEC_PATH).copy()
105
 
106
- # Region filter: keep USA / North America / blank / not specified
107
- def _region_ok(x: Any) -> bool:
108
  s = str(x or "").strip().lower()
109
  if not s:
110
  return True
@@ -121,16 +134,9 @@ def _region_ok(x: Any) -> bool:
121
  return False
122
 
123
  if "region" in df_eos.columns:
124
- df_eos = df_eos[df_eos["region"].apply(_region_ok)].reset_index(drop=True)
125
 
126
- # Optional "Device Type"
127
- device_type_col = None
128
- for c in df_eos.columns:
129
- if norm_text(c) == "device type":
130
- device_type_col = c
131
- break
132
-
133
- # Maker mapping (expanded — adds Teltonika)
134
  CANON_MAKER = {
135
  "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
136
  "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
@@ -140,16 +146,6 @@ CANON_MAKER = {
140
  "CISCO": {"cisco"},
141
  "TELTONIKA": {"teltonika"},
142
  }
143
- DISPLAY_MAKER = {
144
- "CRADLEPOINT": "Cradlepoint",
145
- "SIERRA": "Sierra Wireless",
146
- "FEENEY": "Feeney Wireless",
147
- "DIGI": "Digi",
148
- "CISCO_MERAKI": "Cisco Meraki",
149
- "CISCO": "Cisco",
150
- "TELTONIKA": "Teltonika",
151
- "UNKNOWN": "Unknown",
152
- }
153
 
154
  def canon_maker_from_text(s: Any) -> str:
155
  t = norm_text(s)
@@ -166,7 +162,7 @@ df_eos["_norm_notes"] = df_eos["notes"].apply(norm_text) if "notes" in df_eos.co
166
 
167
  df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in df_dec.columns else "UNKNOWN"
168
  df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
169
- df_dec["_is5g"] = df_dec["Modem Type"].apply(_is_5g) if "Modem Type" in df_dec.columns else False
170
 
171
 
172
  # ============================
@@ -207,12 +203,12 @@ def parse_date_field(x: Any) -> ParsedDate:
207
 
208
  return ParsedDate(raw=raw, kind="bad", value=None)
209
 
210
- def display_date(parsed: ParsedDate) -> str:
211
- if parsed.kind == "missing":
212
  return "Not listed"
213
- if parsed.kind == "bad":
214
- return parsed.raw or "Not listed"
215
- return parsed.raw
216
 
217
  def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
218
  if eos.value is None and eol.value is None:
@@ -223,9 +219,9 @@ def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
223
  return "End of Sale"
224
  return "Active"
225
 
226
- def row_to_dates_and_status(life_row: pd.Series) -> Tuple[str, str, str]:
227
- eos = parse_date_field(life_row.get("end_of_sale"))
228
- eol = parse_date_field(life_row.get("end_of_life"))
229
  return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)
230
 
231
 
@@ -262,23 +258,18 @@ parsec_index.add(parsec_emb)
262
 
263
 
264
  # ============================
265
- # Device resolution (exact SKU -> GPT A/B)
266
  # ============================
267
- def _label_for_row(i: int) -> str:
268
  r = df_eos.iloc[i]
269
  return f"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}"[:220]
270
 
271
- EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]
272
  EOS_CORPUS = []
273
  for _, r in df_eos.iterrows():
274
- EOS_CORPUS.append(" ".join([
275
- r.get("_norm_sku",""),
276
- r.get("_canon_make",""),
277
- r.get("_norm_desc",""),
278
- r.get("_norm_notes",""),
279
- ]))
280
-
281
- def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int,int,str]]:
282
  q = norm_text(query)
283
  hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)
284
  return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
@@ -291,20 +282,20 @@ def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> D
291
  "user_input": user_text,
292
  "candidates": [{"row_idx": i, "score": s, "label": lbl} for (i,s,lbl) in candidates],
293
  "rules": [
294
- "If one candidate is clearly correct, return mode='ok' with row_idx.",
295
  "If two are plausible, return mode='pick' with top 2 options."
296
  ],
297
  "output_schema": {"mode":"ok|pick","row_idx":"int","options":[{"row_idx":"int","label":"string"}]}
298
  }
299
- return gpt_json(sys, payload, max_tokens=300)
300
 
301
  def resolve_device(user_text: str) -> Dict[str, Any]:
302
  q = norm_text(user_text)
303
- exact_idxs = df_eos.index[df_eos["_norm_sku"] == q].tolist()
304
- if len(exact_idxs) == 1:
305
- return {"mode":"ok","row_idx": int(exact_idxs[0])}
306
- if len(exact_idxs) > 1:
307
- opts = [{"row_idx": int(i), "label": EOS_LABELS[int(i)]} for i in exact_idxs[:2]]
308
  return {"mode":"pick","options": opts}
309
 
310
  cands = local_candidates(user_text, top_k=6)
@@ -324,71 +315,90 @@ def resolve_device(user_text: str) -> Dict[str, Any]:
324
  if opts2:
325
  return {"mode":"pick","options": opts2}
326
 
327
- # fallback
328
  if len(cands) > 1:
329
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
330
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
331
 
332
 
333
  # ============================
334
- # Replacements — lifecycle CSV is source of truth
335
- # Fix: always show 4G alternative if lifecycle suggests it (even if Active)
336
  # ============================
337
- def _extract_model_token(text: str) -> str:
338
- s = _safe_str(text)
339
  if not s:
340
  return ""
341
  parts = [p.strip() for p in s.split("|") if p.strip()]
342
  candidates = parts[::-1] if parts else [s]
343
-
344
  for cand in candidates:
345
- # Teltonika family
346
  m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
347
  if m:
348
  return m.group(0).upper()
349
- # Digi IX-series
350
  m = re.search(r"\bIX\d{2}\b", cand, flags=re.IGNORECASE)
351
  if m:
352
  return m.group(0).upper()
353
- # Cradlepoint R/E/S
354
  m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", cand, flags=re.IGNORECASE)
355
  if m:
356
  return m.group(0).upper()
357
- # Generic model token
358
  m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
359
  if m:
360
  return m.group(0).upper()
361
-
362
  return candidates[0][:60]
363
 
364
- def _device_is_4g(life_row: pd.Series) -> bool:
365
- t = norm_text(life_row.get("description","")) + " " + norm_text(life_row.get("notes",""))
366
- return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
- def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
369
- # Pool within same manufacturer text (not just canon) to support Teltonika etc
370
  mfr = norm_text(manufacturer)
371
  pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
372
  vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
373
  out, seen = [], set()
374
  for v in vals:
375
- tok = _extract_model_token(v)
376
  if tok and tok.lower() != "nan" and tok not in seen:
377
  seen.add(tok); out.append(tok)
378
  return out
379
 
380
- def _candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:
381
  mfr = norm_text(manufacturer)
382
  pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
383
  vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
384
  out, seen = [], set()
385
  for v in vals:
386
- tok = _extract_model_token(v)
387
  if tok and tok.lower() != "nan" and tok not in seen:
388
  seen.add(tok); out.append(tok)
389
  return out
390
 
391
- def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:
392
  if client is None or not candidates:
393
  return ""
394
  sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
@@ -406,70 +416,65 @@ def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: s
406
  choice = str(out.get("choice","") or "").strip()
407
  return choice if choice in candidates else ""
408
 
409
- def _fallback_5g_from_dec(canon_make: str) -> str:
410
  pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
411
  return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
412
 
413
- def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, Any]:
414
- canon = str(life_row.get("_canon_make","UNKNOWN"))
415
- manufacturer = str(life_row.get("manufacturer","") or "")
416
 
417
- is_4g_device = _device_is_4g(life_row)
418
- needs_4g_repl = is_4g_device and (status in {"End of Sale","End of Life"})
419
- want_5g = is_4g_device or (status in {"End of Sale","End of Life"})
420
 
421
- # 4G alternative: ALWAYS if suggested_replacement exists for 4G devices
 
 
 
 
 
 
 
 
 
422
  repl_4g = "Not applicable"
423
- if is_4g_device:
424
- repl_4g = _extract_model_token(_safe_str(life_row.get("suggested_replacement","")))
425
  if not repl_4g:
426
- cand4 = _candidate_4g_models_from_lifecycle(manufacturer)
427
- repl_4g = _gpt_pick_from_candidates(life_row, cand4, "4G alternative") or (cand4[0] if cand4 else "")
428
  if not repl_4g:
429
  repl_4g = "Not applicable"
430
 
431
- # 5G replacement: ALWAYS when want_5g is true
432
- repl_5g = "Not applicable"
433
  if want_5g:
434
- repl_5g = _extract_model_token(_safe_str(life_row.get("advanced_5g_option","")))
435
  if not repl_5g:
436
- cand5 = _candidate_5g_models_from_lifecycle(manufacturer)
437
- repl_5g = _gpt_pick_from_candidates(life_row, cand5, "5G replacement/upgrade") or (cand5[0] if cand5 else "")
438
  if not repl_5g:
439
- # last resort: dec catalog fallback
440
- repl_5g = _fallback_5g_from_dec(canon)
441
 
442
  if repl_5g.lower() == "nan":
443
- repl_5g = ""
444
 
445
- return {
446
- "repl_4g": repl_4g,
447
- "repl_5g": repl_5g,
448
- "why": "Lifecycle replacements (GPT fallback when missing).",
449
- "sources": ["lifecycle_csv"] + (["gpt"] if client else []) + (["dec_fallback"] if (want_5g and not repl_5g) else []),
450
- }
451
 
452
 
453
  # ============================
454
- # Antennas (Parsec-only; family name extraction)
455
  # ============================
456
- PARSEC_FAMILY_WORDS = {
457
- "chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie",
458
- "shepherd","belgian","australian","terrier","pyrenees"
459
- }
460
- BAD_NAME_MARKERS = {
461
- "customization", "standard connectors", "connectors", "features", "benefits",
462
- "specifications", "mechanical", "electrical", "mounting", "accessories",
463
- "description:", "standard sku"
464
- }
465
 
466
- def _clean_line(s: str) -> str:
467
  s = re.sub(r"\s+", " ", str(s or "").strip())
468
  if re.fullmatch(r"-[a-z0-9]+", s.lower()):
469
  return ""
470
  return s
471
 
472
- def _is_bad_name_line(line: str) -> bool:
473
  low = line.lower()
474
  if any(m in low for m in BAD_NAME_MARKERS):
475
  return True
@@ -477,25 +482,43 @@ def _is_bad_name_line(line: str) -> bool:
477
  return True
478
  return False
479
 
480
- def _family_from_line(line: str) -> str:
481
  low = line.lower()
482
  for fam in PARSEC_FAMILY_WORDS:
483
  if fam in low:
484
  return fam.capitalize()
485
  return ""
486
 
487
- def _parsec_name_from_card(card_text: str) -> str:
488
- lines = [_clean_line(ln) for ln in str(card_text or "").splitlines()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  lines = [ln for ln in lines if ln]
490
 
491
  for ln in lines:
492
- if _is_bad_name_line(ln):
493
  continue
494
- fam = _family_from_line(ln)
495
  if fam:
496
  return fam
497
 
498
- # fallback near SKU line
499
  sku_i = None
500
  for i, ln in enumerate(lines):
501
  if "standard sku" in ln.lower():
@@ -504,150 +527,231 @@ def _parsec_name_from_card(card_text: str) -> str:
504
  if sku_i is not None:
505
  window = lines[max(0, sku_i - 12):sku_i]
506
  for ln in reversed(window):
507
- if _is_bad_name_line(ln):
508
  continue
509
  if 3 <= len(ln) <= 40 and re.search(r"[A-Za-z]", ln):
510
  return ln.split()[0].capitalize()
511
 
512
  return "Parsec antenna"
513
 
514
- def _parsec_part_from_card(t: str) -> str:
515
  m = re.search(r"Standard\s+SKU:\s*([A-Z0-9]+)", t)
516
  return m.group(1).strip() if m else ""
517
 
518
- def _parsec_desc_from_card(t: str) -> str:
519
  m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
520
  return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
521
 
522
- def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
523
  qv = embedder.encode([query], normalize_embeddings=True)
524
  qv = np.asarray(qv, dtype=np.float32)
525
  scores, ids = parsec_index.search(qv, top_k)
526
- out = []
527
  for sc, i in zip(scores[0].tolist(), ids[0].tolist()):
528
  if 0 <= int(i) < len(parsec_cards):
529
  card = parsec_cards[int(i)]
530
  out.append({
531
  "score": float(sc),
532
- "name": _parsec_name_from_card(card),
533
- "part_number": _parsec_part_from_card(card),
534
- "description": _parsec_desc_from_card(card),
 
 
 
535
  })
536
  return out
537
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
539
- q_stationary = f"{router_model} {tech} {mimo} omni stationary outdoor Parsec"
540
- q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
541
- cand_stationary = parsec_retrieve(q_stationary, top_k=10)
542
- cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
543
-
544
- # deterministic fallback if no GPT
545
- s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":""}
546
- v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":""}
 
547
  s.update({"mimo": mimo, "why": "Stationary omni best match."})
548
  v.update({"mimo": mimo, "why": "Vehicle omni best match."})
 
549
  return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
550
 
551
 
552
  # ============================
553
- # Feature table + GPT fill for missing fields
554
  # ============================
555
- FEATURE_COLS = ["Name","Modem technology","WiFi","Ports","Antennas","Ruggedness","Use case"]
556
-
557
- def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:
558
- if not model or model in {"Not applicable","Not listed"}:
559
- return {k:"Not listed" for k in FEATURE_COLS}
560
- pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
561
- if pool.empty:
562
- return {k:"Not listed" for k in FEATURE_COLS}
563
- hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
564
- if not hit or hit[1] < MATCH_OK:
565
- return {k:"Not listed" for k in FEATURE_COLS}
566
- r = pool.iloc[int(hit[2])]
567
- ports = f"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}"
568
- return {
569
- "Name": str(r.get("Model","")),
570
- "Modem technology": str(r.get("Modem Type","")),
571
- "WiFi": str(r.get("WiFi type","")),
572
- "Ports": ports,
573
- "Antennas": str(r.get("Antennas (internal/external/both)","")),
574
- "Ruggedness": str(r.get("Ruggedization","")),
575
- "Use case": str(r.get("Primary use case","")),
576
- }
577
 
578
- def gpt_fill_features(device_label: str, feats: Dict[str,str], context: str) -> Dict[str,str]:
579
- missing = [k for k,v in feats.items() if (not v) or v.strip().lower() in {"not listed","nan"}]
580
- if client is None or not missing:
581
- return feats
582
- sys = "Fill missing router feature fields. Return strict JSON only."
583
- payload = {
584
- "device": device_label,
585
- "known": feats,
586
- "context": context[:2000],
587
- "fill_only": missing,
588
- "rules": ["Fill only requested fields. Best guess if needed. Return JSON only."],
589
- "output_schema": {k:"string" for k in missing}
590
- }
591
- out = gpt_json(sys, payload, max_tokens=350) or {}
592
- for k in missing:
593
- v = str(out.get(k,"") or "").strip()
594
- if v:
595
- feats[k] = v
596
- return feats
597
-
598
- def current_features_guess(life_row: pd.Series) -> Dict[str,str]:
599
- sku = str(life_row.get("sku","") or "").strip()
600
- desc = str(life_row.get("description","") or "").strip()
601
- notes = str(life_row.get("notes","") or "").strip()
602
- base = {
603
- "Name": sku,
604
- "Modem technology": "4G" if _device_is_4g(life_row) else ("5G" if ("5g" in (desc+notes).lower() or "nr" in (desc+notes).lower()) else "Not listed"),
605
- "WiFi": "Not listed",
606
- "Ports": "Not listed",
607
- "Antennas": "Not listed",
608
- "Ruggedness": "Not listed",
609
- "Use case": "Not listed",
610
- }
611
- return gpt_fill_features("Current device", base, f"{desc}\n{notes}")
612
-
613
- def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str]) -> str:
614
- cols = ["Device", "Modem technology", "WiFi", "Ports", "Antennas", "Ruggedness", "Use case"]
615
- header = "| " + " | ".join(cols) + " |"
616
- sep = "| " + " | ".join(["---"]*len(cols)) + " |"
617
- def row(name: str, feats: Dict[str,str]) -> str:
618
- return "| " + " | ".join([
619
- name,
620
- feats.get("Modem technology","Not listed"),
621
- feats.get("WiFi","Not listed"),
622
- feats.get("Ports","Not listed"),
623
- feats.get("Antennas","Not listed"),
624
- feats.get("Ruggedness","Not listed"),
625
- feats.get("Use case","Not listed"),
626
- ]) + " |"
627
- return "\n".join([header, sep, row("Current", cur), row("4G alternative", r4), row("5G replacement", r5)])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
 
629
 
630
  # ============================
631
- # Output + Gradio
632
  # ============================
633
  def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
634
- canon_make = str(life_row.get("_canon_make","UNKNOWN"))
635
  current_name = f"{life_row.get('sku','')} — {life_row.get('description','')}".strip(" —")
636
-
637
  st = ant.get("stationary_omni", {})
638
  vh = ant.get("vehicle_omni", {})
639
 
640
- cur_feats = current_features_guess(life_row)
641
- r4_feats = dec_features_by_model(repl.get("repl_4g",""), canon_make)
642
- r5_feats = dec_features_by_model(repl.get("repl_5g",""), canon_make)
643
-
644
- # If dec doesn't know the model, ask GPT to fill missing cells (best guess)
645
- if client is not None:
646
- r4_feats = gpt_fill_features("4G alternative", r4_feats, f"Model: {repl.get('repl_4g','')}\nMake: {canon_make}")
647
- r5_feats = gpt_fill_features("5G replacement", r5_feats, f"Model: {repl.get('repl_5g','')}\nMake: {canon_make}")
648
-
649
- table_md = build_features_table(cur_feats, r4_feats, r5_feats)
650
-
651
  lines = []
652
  lines.append(f"1. Current device: **{current_name}**")
653
  lines.append(f"2. Status: **{status}**")
@@ -656,49 +760,59 @@ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl:
656
  lines.append(f"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**")
657
  lines.append(f"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**")
658
  lines.append("7. Antenna options (Parsec-only):")
659
- lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) {st.get('description','')} — MIMO: {st.get('mimo','')} — {st.get('why','')}")
660
- lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) {vh.get('description','')} — MIMO: {vh.get('mimo','')} — {vh.get('why','')}")
661
- lines.append("8. Recommended features table:")
662
- lines.append(table_md)
 
663
  lines.append("\nSources (debug):")
664
  for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
665
  lines.append(f"- {s}")
666
  lines.append("- ParsecCatalog.pdf (local RAG)")
667
  lines.append("- routers_eos_eol_by_sku.csv (replacements)")
668
- lines.append("- dec2025routers.csv (features)")
669
  return "\n".join(lines)
670
 
671
- def run_lookup(user_text: str, st: Dict[str,Any]):
 
 
 
 
 
672
  user_text = str(user_text or "").strip()
673
  if not user_text:
674
- return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), {}
675
 
676
  res = resolve_device(user_text)
 
677
  if res.get("mode") == "pick":
678
  opts = res.get("options", [])
679
  choices = [o["label"] for o in opts]
680
- st2 = {"mode":"pick","options": opts}
681
- return "Did you mean A or B? Pick one, then click Use selection.", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), st2
682
 
683
  if res.get("mode") != "ok":
684
- return "Not found.", gr.update(visible=False), gr.update(visible=False), {}
685
 
686
  life_row = df_eos.iloc[int(res["row_idx"])]
687
  eos, eol, status = row_to_dates_and_status(life_row)
688
 
689
- repl = pick_replacements_lifecycle(life_row, status)
690
-
691
- tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
692
- mimo_guess = "4x4" if tech == "5G" else "2x2"
693
- ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
694
 
695
- return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}
 
 
696
 
697
- def use_selection(selected_label: str, st: Dict[str,Any]):
 
698
  if not st or st.get("mode") != "pick":
699
- return "Run a search first.", gr.update(visible=False), gr.update(visible=False), {}
 
700
  if not selected_label:
701
- return "Pick A or B first.", gr.update(visible=True), gr.update(visible=True), st
702
 
703
  chosen_row = None
704
  for o in st.get("options", []):
@@ -706,29 +820,67 @@ def use_selection(selected_label: str, st: Dict[str,Any]):
706
  chosen_row = int(o["row_idx"])
707
  break
708
  if chosen_row is None:
709
- return "Pick a valid option.", gr.update(visible=True), gr.update(visible=True), st
710
 
711
  life_row = df_eos.iloc[int(chosen_row)]
712
  eos, eol, status = row_to_dates_and_status(life_row)
713
- repl = pick_replacements_lifecycle(life_row, status)
714
- tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
715
- mimo_guess = "4x4" if tech == "5G" else "2x2"
716
- ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
717
 
718
- return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719
 
 
 
 
 
720
  with gr.Blocks(title="Only-Routers") as demo:
721
- gr.Markdown("## Only-Routers\nEnter a router SKU/model. If ambiguous, you’ll get A/B choices.")
722
- user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
723
- st = gr.State({})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
 
725
- check_btn = gr.Button("Check", variant="primary")
726
- pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
727
- use_btn = gr.Button("Use selection", visible=False)
 
 
 
728
 
729
- output_md = gr.Markdown()
 
 
 
730
 
731
- check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st])
732
- use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st])
733
 
734
- demo.launch()
 
 
3
  import json
4
  import math
5
  import hashlib
6
+ import tempfile
7
  from dataclasses import dataclass
8
  from datetime import datetime, date
9
+ from typing import Any, Dict, List, Optional, Tuple
10
 
11
  import numpy as np
12
  import pandas as pd
 
26
  TODAY = date(2026, 1, 18)
27
  OPENAI_MODEL = "gpt-5.2"
28
  OPENAI_REASONING = {"effort": "high"}
 
29
  MATCH_OK = 80
30
+
31
  EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
32
  PARSEC_CONTEXT_BEFORE = 900
33
  PARSEC_CONTEXT_AFTER = 1600
34
 
 
 
 
35
 
36
  # ============================
37
  # OpenAI client (HF Space secret: OPENAI_API_KEY)
 
39
  API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
40
  client = OpenAI(api_key=API_KEY) if API_KEY else None
41
 
42
+ # ----------------------------
43
+ # Gradio state helpers
44
+ # Keep state as a JSON STRING to avoid schema issues on Hugging Face.
45
+ # ----------------------------
46
+ def state_load(st_json: str) -> Dict[str, Any]:
47
+ try:
48
+ if not st_json:
49
+ return {}
50
+ return json.loads(st_json) if isinstance(st_json, str) else {}
51
+ except Exception:
52
+ return {}
53
+
54
+ def state_dump(st: Dict[str, Any]) -> str:
55
+ try:
56
+ return json.dumps(st or {}, ensure_ascii=False)
57
+ except Exception:
58
+ return "{}"
59
+
60
+
61
 
62
  # ============================
63
+ # Helpers
64
  # ============================
65
  def norm_text(s: Any) -> str:
66
  try:
 
73
  s = re.sub(r"\s+", " ", s).strip()
74
  return s
75
 
76
+ def safe_str(v: Any) -> str:
77
  if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):
78
  return ""
79
  return str(v).strip()
80
 
81
+ def is_5g(modem_type: Any) -> bool:
82
  s = norm_text(modem_type)
83
  return ("5g" in s) or ("nr" in s)
84
 
85
+ def json_load_safe(s: str) -> Dict[str, Any]:
86
  try:
87
  return json.loads(s)
88
  except Exception:
89
  return {}
90
 
91
+ def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 600) -> Dict[str, Any]:
92
  if client is None:
93
  return {}
94
  resp = client.responses.create(
95
  model=OPENAI_MODEL,
96
  reasoning=OPENAI_REASONING,
97
+ input=[{"role":"system","content":system},{"role":"user","content":json.dumps(payload)}],
 
 
 
98
  max_output_tokens=max_tokens,
99
  )
100
+ return json_load_safe(getattr(resp, "output_text", "") or "")
101
 
102
 
103
  # ============================
104
+ # Load data
105
  # ============================
106
  EOS_PATH = "routers_eos_eol_by_sku.csv"
107
  DEC_PATH = "dec2025routers.csv"
 
117
  df_eos = pd.read_csv(EOS_PATH).copy()
118
  df_dec = pd.read_csv(DEC_PATH).copy()
119
 
120
+ def region_ok(x: Any) -> bool:
 
121
  s = str(x or "").strip().lower()
122
  if not s:
123
  return True
 
134
  return False
135
 
136
  if "region" in df_eos.columns:
137
+ df_eos = df_eos[df_eos["region"].apply(region_ok)].reset_index(drop=True)
138
 
139
+ # Maker mapping (includes Teltonika)
 
 
 
 
 
 
 
140
  CANON_MAKER = {
141
  "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
142
  "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
 
146
  "CISCO": {"cisco"},
147
  "TELTONIKA": {"teltonika"},
148
  }
 
 
 
 
 
 
 
 
 
 
149
 
150
  def canon_maker_from_text(s: Any) -> str:
151
  t = norm_text(s)
 
162
 
163
  df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in df_dec.columns else "UNKNOWN"
164
  df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
165
+ df_dec["_is5g"] = df_dec["Modem Type"].apply(is_5g) if "Modem Type" in df_dec.columns else False
166
 
167
 
168
  # ============================
 
203
 
204
  return ParsedDate(raw=raw, kind="bad", value=None)
205
 
206
+ def display_date(pd_: ParsedDate) -> str:
207
+ if pd_.kind == "missing":
208
  return "Not listed"
209
+ if pd_.kind == "bad":
210
+ return pd_.raw or "Not listed"
211
+ return pd_.raw
212
 
213
  def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
214
  if eos.value is None and eol.value is None:
 
219
  return "End of Sale"
220
  return "Active"
221
 
222
+ def row_to_dates_and_status(row: pd.Series) -> Tuple[str, str, str]:
223
+ eos = parse_date_field(row.get("end_of_sale"))
224
+ eol = parse_date_field(row.get("end_of_life"))
225
  return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)
226
 
227
 
 
258
 
259
 
260
  # ============================
261
+ # Device resolution
262
  # ============================
263
+ def label_for_row(i: int) -> str:
264
  r = df_eos.iloc[i]
265
  return f"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}"[:220]
266
 
267
+ EOS_LABELS = [label_for_row(i) for i in range(len(df_eos))]
268
  EOS_CORPUS = []
269
  for _, r in df_eos.iterrows():
270
+ EOS_CORPUS.append(" ".join([r.get("_norm_sku",""), r.get("_canon_make",""), r.get("_norm_desc",""), r.get("_norm_notes","")]))
271
+
272
+ def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int, int, str]]:
 
 
 
 
 
273
  q = norm_text(query)
274
  hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)
275
  return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
 
282
  "user_input": user_text,
283
  "candidates": [{"row_idx": i, "score": s, "label": lbl} for (i,s,lbl) in candidates],
284
  "rules": [
285
+ "If one is clearly correct, return mode='ok' with row_idx.",
286
  "If two are plausible, return mode='pick' with top 2 options."
287
  ],
288
  "output_schema": {"mode":"ok|pick","row_idx":"int","options":[{"row_idx":"int","label":"string"}]}
289
  }
290
+ return gpt_json(sys, payload, max_tokens=280)
291
 
292
  def resolve_device(user_text: str) -> Dict[str, Any]:
293
  q = norm_text(user_text)
294
+ exact = df_eos.index[df_eos["_norm_sku"] == q].tolist()
295
+ if len(exact) == 1:
296
+ return {"mode":"ok","row_idx": int(exact[0])}
297
+ if len(exact) > 1:
298
+ opts = [{"row_idx": int(i), "label": EOS_LABELS[int(i)]} for i in exact[:2]]
299
  return {"mode":"pick","options": opts}
300
 
301
  cands = local_candidates(user_text, top_k=6)
 
315
  if opts2:
316
  return {"mode":"pick","options": opts2}
317
 
 
318
  if len(cands) > 1:
319
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
320
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
321
 
322
 
323
  # ============================
324
+ # Replacements — lifecycle CSV source of truth
 
325
  # ============================
326
+ def extract_model_token(text: str) -> str:
327
+ s = safe_str(text)
328
  if not s:
329
  return ""
330
  parts = [p.strip() for p in s.split("|") if p.strip()]
331
  candidates = parts[::-1] if parts else [s]
 
332
  for cand in candidates:
 
333
  m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
334
  if m:
335
  return m.group(0).upper()
 
336
  m = re.search(r"\bIX\d{2}\b", cand, flags=re.IGNORECASE)
337
  if m:
338
  return m.group(0).upper()
 
339
  m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", cand, flags=re.IGNORECASE)
340
  if m:
341
  return m.group(0).upper()
 
342
  m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
343
  if m:
344
  return m.group(0).upper()
 
345
  return candidates[0][:60]
346
 
347
+ def device_is_4g(row: pd.Series) -> bool:
348
+ # Detect LTE/4G even when the description uses "Cat 4 / Cat6 / Cat 12" without saying "LTE"
349
+ t = norm_text(row.get("description","")) + " " + norm_text(row.get("notes",""))
350
+
351
+ # If it explicitly says 5G/NR, treat as not 4G-only
352
+ if ("5g" in t) or ("nr" in t):
353
+ return False
354
+
355
+ # Classic signals
356
+ if ("lte" in t) or ("4g" in t):
357
+ return True
358
+
359
+ # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)
360
+ if re.search(r"\bcat\s*[-]?\s*(m1|m2)\b", t):
361
+ return True
362
+
363
+ m = re.search(r"\bcat\s*[-]?\s*(\d{1,2})\b", t)
364
+ if m:
365
+ try:
366
+ cat = int(m.group(1))
367
+ if 0 < cat <= 20:
368
+ return True
369
+ except Exception:
370
+ pass
371
+
372
+ # If "cat" appears at all, it's almost always LTE-family
373
+ if "cat" in t:
374
+ return True
375
+
376
+ return False
377
+
378
 
379
+ def candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
 
380
  mfr = norm_text(manufacturer)
381
  pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
382
  vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
383
  out, seen = [], set()
384
  for v in vals:
385
+ tok = extract_model_token(v)
386
  if tok and tok.lower() != "nan" and tok not in seen:
387
  seen.add(tok); out.append(tok)
388
  return out
389
 
390
+ def candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:
391
  mfr = norm_text(manufacturer)
392
  pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
393
  vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
394
  out, seen = [], set()
395
  for v in vals:
396
+ tok = extract_model_token(v)
397
  if tok and tok.lower() != "nan" and tok not in seen:
398
  seen.add(tok); out.append(tok)
399
  return out
400
 
401
+ def gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:
402
  if client is None or not candidates:
403
  return ""
404
  sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
 
416
  choice = str(out.get("choice","") or "").strip()
417
  return choice if choice in candidates else ""
418
 
419
+ def fallback_5g_from_dec(canon_make: str) -> str:
420
  pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
421
  return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
422
 
423
+ def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:
424
+ canon = str(row.get("_canon_make","UNKNOWN"))
425
+ manufacturer = str(row.get("manufacturer","") or "")
426
 
427
+ sug_raw = safe_str(row.get("suggested_replacement",""))
428
+ adv_raw = safe_str(row.get("advanced_5g_option",""))
 
429
 
430
+ has_4g_alt = bool(sug_raw.strip())
431
+ has_5g_alt = bool(adv_raw.strip())
432
+
433
+ # Treat as 4G if the description indicates LTE OR lifecycle provides a 4G suggested replacement
434
+ is_4g = device_is_4g(row) or has_4g_alt
435
+
436
+ # Provide 5G option if the unit is 4G, EOS/EOL, or lifecycle explicitly provides advanced_5g_option
437
+ want_5g = is_4g or (status in {"End of Sale","End of Life"}) or has_5g_alt
438
+
439
+ # 4G alternative: show whenever lifecycle provides it (or device appears 4G)
440
  repl_4g = "Not applicable"
441
+ if is_4g or has_4g_alt:
442
+ repl_4g = extract_model_token(sug_raw)
443
  if not repl_4g:
444
+ cand4 = candidate_4g_models_from_lifecycle(manufacturer)
445
+ repl_4g = (gpt_pick_from_candidates(row, cand4, "4G alternative") if (use_gpt and client) else "") or (cand4[0] if cand4 else "")
446
  if not repl_4g:
447
  repl_4g = "Not applicable"
448
 
449
+ # 5G replacement: prefer lifecycle advanced_5g_option whenever present
450
+ repl_5g = "Not listed"
451
  if want_5g:
452
+ repl_5g = extract_model_token(adv_raw)
453
  if not repl_5g:
454
+ cand5 = candidate_5g_models_from_lifecycle(manufacturer)
455
+ repl_5g = (gpt_pick_from_candidates(row, cand5, "5G replacement/upgrade") if (use_gpt and client) else "") or (cand5[0] if cand5 else "")
456
  if not repl_5g:
457
+ repl_5g = fallback_5g_from_dec(canon) or "Not listed"
 
458
 
459
  if repl_5g.lower() == "nan":
460
+ repl_5g = "Not listed"
461
 
462
+ return {"repl_4g": repl_4g, "repl_5g": repl_5g, "sources": ["lifecycle_csv"] + (["gpt"] if (use_gpt and client) else [])}
 
 
 
 
 
463
 
464
 
465
  # ============================
466
+ # Antennas (Parsec-only)
467
  # ============================
468
+ PARSEC_FAMILY_WORDS = {"chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie","shepherd","belgian","australian","terrier","pyrenees"}
469
+ BAD_NAME_MARKERS = {"customization","standard connectors","connectors","features","benefits","specifications","mechanical","electrical","mounting","accessories","description:","standard sku"}
 
 
 
 
 
 
 
470
 
471
+ def clean_line(s: str) -> str:
472
  s = re.sub(r"\s+", " ", str(s or "").strip())
473
  if re.fullmatch(r"-[a-z0-9]+", s.lower()):
474
  return ""
475
  return s
476
 
477
+ def is_bad_name_line(line: str) -> bool:
478
  low = line.lower()
479
  if any(m in low for m in BAD_NAME_MARKERS):
480
  return True
 
482
  return True
483
  return False
484
 
485
+ def family_from_line(line: str) -> str:
486
  low = line.lower()
487
  for fam in PARSEC_FAMILY_WORDS:
488
  if fam in low:
489
  return fam.capitalize()
490
  return ""
491
 
492
+ def parsec_connectors_from_card(t: str) -> str:
493
+ m = re.search(r"Standard\s+Connectors:\s*(.+)", t, flags=re.IGNORECASE)
494
+ if m:
495
+ return re.sub(r"\s+", " ", m.group(1).strip())[:80]
496
+ return ""
497
+
498
+ def parsec_mounts_from_card(t: str) -> List[str]:
499
+ mounts = []
500
+ for m in re.finditer(r"Mount:\s*(.+)", t, flags=re.IGNORECASE):
501
+ val = re.sub(r"\s+", " ", m.group(1).strip())
502
+ parts = [p.strip().lower() for p in val.split(",") if p.strip()]
503
+ mounts.extend(parts)
504
+ out = []
505
+ seen = set()
506
+ for x in mounts:
507
+ if x not in seen:
508
+ seen.add(x); out.append(x)
509
+ return out
510
+
511
+ def parsec_name_from_card(card_text: str) -> str:
512
+ lines = [clean_line(ln) for ln in str(card_text or "").splitlines()]
513
  lines = [ln for ln in lines if ln]
514
 
515
  for ln in lines:
516
+ if is_bad_name_line(ln):
517
  continue
518
+ fam = family_from_line(ln)
519
  if fam:
520
  return fam
521
 
 
522
  sku_i = None
523
  for i, ln in enumerate(lines):
524
  if "standard sku" in ln.lower():
 
527
  if sku_i is not None:
528
  window = lines[max(0, sku_i - 12):sku_i]
529
  for ln in reversed(window):
530
+ if is_bad_name_line(ln):
531
  continue
532
  if 3 <= len(ln) <= 40 and re.search(r"[A-Za-z]", ln):
533
  return ln.split()[0].capitalize()
534
 
535
  return "Parsec antenna"
536
 
537
+ def parsec_part_from_card(t: str) -> str:
538
  m = re.search(r"Standard\s+SKU:\s*([A-Z0-9]+)", t)
539
  return m.group(1).strip() if m else ""
540
 
541
+ def parsec_desc_from_card(t: str) -> str:
542
  m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
543
  return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
544
 
545
+ def parsec_retrieve(query: str, top_k: int = 12) -> List[Dict[str, Any]]:
546
  qv = embedder.encode([query], normalize_embeddings=True)
547
  qv = np.asarray(qv, dtype=np.float32)
548
  scores, ids = parsec_index.search(qv, top_k)
549
+ out: List[Dict[str, Any]] = []
550
  for sc, i in zip(scores[0].tolist(), ids[0].tolist()):
551
  if 0 <= int(i) < len(parsec_cards):
552
  card = parsec_cards[int(i)]
553
  out.append({
554
  "score": float(sc),
555
+ "name": parsec_name_from_card(card),
556
+ "part_number": parsec_part_from_card(card),
557
+ "description": parsec_desc_from_card(card),
558
+ "connectors": parsec_connectors_from_card(card),
559
+ "mounts": parsec_mounts_from_card(card),
560
+ "_card": card.lower(),
561
  })
562
  return out
563
 
564
+ def choose_best_parsec(cands: List[Dict[str, Any]], mode: str) -> Dict[str, Any]:
565
+ best = None
566
+ best_score = -1e9
567
+
568
+ for c in cands:
569
+ card = c.get("_card","")
570
+ mounts = c.get("mounts", []) or []
571
+ score = float(c.get("score", 0.0))
572
+
573
+ if "omni" in card:
574
+ score += 0.6
575
+ if "directional" in card:
576
+ score -= 1.5
577
+
578
+ if mode == "vehicle":
579
+ if any("magnetic" in m for m in mounts):
580
+ score += 3.0
581
+ if any("through" in m for m in mounts):
582
+ score += 2.0
583
+ if any("wall" in m for m in mounts) or any("pole" in m for m in mounts):
584
+ score -= 1.2
585
+ if "app: fixed" in card and "mobile" not in card:
586
+ score -= 2.0
587
+
588
+ if mode == "stationary":
589
+ if any("wall" in m for m in mounts):
590
+ score += 2.0
591
+ if any("pole" in m for m in mounts):
592
+ score += 1.8
593
+
594
+ if score > best_score:
595
+ best_score = score
596
+ best = c
597
+
598
+ if not best:
599
+ return {"name":"Parsec antenna","part_number":"","description":"","connectors":"","mounts":[]}
600
+
601
+ best = dict(best)
602
+ best.pop("_card", None)
603
+ return best
604
+
605
+
606
+ def infer_mimo_for_5g(model: str, canon_make: str) -> str:
607
+ """Best-effort MIMO guess for antenna selection (2x2 vs 4x4)."""
608
+ # If model is unknown, default to 2x2 (safer ordering)
609
+ if not model or model in {"Not applicable", "Not listed"}:
610
+ return "2x2"
611
+
612
+ # If the model name hints 5G, lean 4x4
613
+ if "5g" in model.lower() or model.upper().startswith(("R", "E", "S", "IX", "RUTM")):
614
+ default = "4x4"
615
+ else:
616
+ default = "2x2"
617
+
618
+ # Use dec2025routers.csv if we can match the model under the same maker family
619
+ try:
620
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
621
+ if pool.empty:
622
+ return default
623
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
624
+ if not hit or hit[1] < MATCH_OK:
625
+ return default
626
+ row = pool.iloc[int(hit[2])]
627
+ txt2 = (str(row.get("Antennas (internal/external/both)", "")) + " " + str(row.get("Modem Type", "")) + " " + str(row.get("Special notes",""))).lower()
628
+ if "4x4" in txt2 or "4 x 4" in txt2 or "4x 4" in txt2:
629
+ return "4x4"
630
+ if "2x2" in txt2 or "2 x 2" in txt2:
631
+ return "2x2"
632
+ # If modem type includes 5G, lean 4x4
633
+ if "5g" in txt2 or "nr" in txt2:
634
+ return "4x4"
635
+ return default
636
+ except Exception:
637
+ return default
638
+
639
  def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
640
+ q_stationary = f"{router_model} {tech} {mimo} omni stationary pole wall fixed site Parsec"
641
+ q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile magnetic through-bolt Parsec"
642
+
643
+ cand_stationary = parsec_retrieve(q_stationary, top_k=12)
644
+ cand_vehicle = parsec_retrieve(q_vehicle, top_k=12)
645
+
646
+ s = choose_best_parsec(cand_stationary, mode="stationary")
647
+ v = choose_best_parsec(cand_vehicle, mode="vehicle")
648
+
649
  s.update({"mimo": mimo, "why": "Stationary omni best match."})
650
  v.update({"mimo": mimo, "why": "Vehicle omni best match."})
651
+
652
  return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
653
 
654
 
655
  # ============================
656
+ # Install-ready checklist
657
  # ============================
658
+ def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
659
+ st = ant.get("stationary_omni", {})
660
+ vh = ant.get("vehicle_omni", {})
661
+ if client is not None:
662
+ sys = "Create a short, install-ready checklist for a Verizon rep. Return markdown only."
663
+ payload = {"current_device": current_sku, "replacements": repl, "antennas": {"stationary": st, "vehicle": vh}}
664
+ resp = client.responses.create(
665
+ model=OPENAI_MODEL,
666
+ reasoning=OPENAI_REASONING,
667
+ input=[{"role":"system","content":sys},{"role":"user","content":json.dumps(payload)}],
668
+ max_output_tokens=520,
669
+ )
670
+ return (getattr(resp, "output_text", "") or "").strip()
671
+ return "\n".join([
672
+ "### Install-ready checklist",
673
+ f"- Current device: {current_sku}",
674
+ f"- 5G replacement: {repl.get('repl_5g','')}",
675
+ f"- 4G alternative: {repl.get('repl_4g','Not applicable')}",
676
+ f"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})",
677
+ f"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})",
678
+ "- Next steps: confirm mounting + cable lengths + power; place order; schedule install.",
679
+ ])
680
 
681
+
682
+ # ============================
683
+ # Batch mode (NO GPT)
684
+ # ============================
685
+ def parse_batch_inputs(text_blob: str, file_obj: Any) -> List[str]:
686
+ items: List[str] = []
687
+ if file_obj is not None:
688
+ try:
689
+ path = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
690
+ df = pd.read_csv(path)
691
+ col = df.columns[0]
692
+ items.extend([str(x).strip() for x in df[col].tolist() if str(x).strip()])
693
+ except Exception:
694
+ pass
695
+ if text_blob:
696
+ for ln in str(text_blob).splitlines():
697
+ ln = ln.strip()
698
+ if ln:
699
+ items.append(ln)
700
+ seen=set()
701
+ out=[]
702
+ for x in items:
703
+ k=norm_text(x)
704
+ if k and k not in seen:
705
+ seen.add(k); out.append(x)
706
+ return out
707
+
708
+ def run_batch(text_blob: str, file_obj: Any, include_antennas: bool):
709
+ inputs = parse_batch_inputs(text_blob, file_obj)
710
+ if not inputs:
711
+ return "", None, None, ""
712
+
713
+ rows=[]
714
+ for item in inputs:
715
+ res = resolve_device(item)
716
+ if res.get("mode") != "ok":
717
+ rows.append({"Input": item, "Matched":"", "Status":"Needs review", "EOS":"", "EOL":"", "4G alternative":"", "5G replacement":"", "Notes":"Not found/ambiguous"})
718
+ continue
719
+
720
+ life_row = df_eos.iloc[int(res["row_idx"])]
721
+ eos, eol, status = row_to_dates_and_status(life_row)
722
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=False)
723
+
724
+ rows.append({
725
+ "Input": item,
726
+ "Matched": str(life_row.get("sku","")),
727
+ "Status": status,
728
+ "EOS": eos,
729
+ "EOL": eol,
730
+ "4G alternative": repl.get("repl_4g",""),
731
+ "5G replacement": repl.get("repl_5g",""),
732
+ "Notes": "",
733
+ })
734
+
735
+ out_df = pd.DataFrame(rows)
736
+ counts = out_df["Status"].value_counts(dropna=False).to_dict()
737
+ top_5g = out_df["5G replacement"].value_counts(dropna=False).head(5).to_dict()
738
+ summary = f"Rows: {len(out_df)} | " + " | ".join([f"{k}: {v}" for k,v in counts.items()])
739
+ rollup = "Top 5G recommendations:\n" + "\n".join([f"- {k}: {v}" for k,v in top_5g.items() if str(k).strip()])
740
+
741
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
742
+ out_df.to_csv(tmp.name, index=False)
743
+
744
+ return summary, out_df, tmp.name, rollup
745
 
746
 
747
  # ============================
748
+ # Output
749
  # ============================
750
  def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
 
751
  current_name = f"{life_row.get('sku','')} — {life_row.get('description','')}".strip(" —")
 
752
  st = ant.get("stationary_omni", {})
753
  vh = ant.get("vehicle_omni", {})
754
 
 
 
 
 
 
 
 
 
 
 
 
755
  lines = []
756
  lines.append(f"1. Current device: **{current_name}**")
757
  lines.append(f"2. Status: **{status}**")
 
760
  lines.append(f"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**")
761
  lines.append(f"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**")
762
  lines.append("7. Antenna options (Parsec-only):")
763
+ conn_s = f" | Conn: {st.get('connectors','')}" if st.get("connectors") else ""
764
+ conn_v = f" | Conn: {vh.get('connectors','')}" if vh.get("connectors") else ""
765
+ lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s}")
766
+ lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v}")
767
+
768
  lines.append("\nSources (debug):")
769
  for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
770
  lines.append(f"- {s}")
771
  lines.append("- ParsecCatalog.pdf (local RAG)")
772
  lines.append("- routers_eos_eol_by_sku.csv (replacements)")
 
773
  return "\n".join(lines)
774
 
775
+
776
+ # ============================
777
+ # Gradio callbacks
778
+ # IMPORTANT: no dict state and ALL events have api_name=False (prevents api_info schema generation)
779
+ # ============================
780
+ def run_lookup(user_text: str, st_json: str):
781
  user_text = str(user_text or "").strip()
782
  if not user_text:
783
+ return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), "{}", ""
784
 
785
  res = resolve_device(user_text)
786
+
787
  if res.get("mode") == "pick":
788
  opts = res.get("options", [])
789
  choices = [o["label"] for o in opts]
790
+ st2 = {"mode":"pick","options": opts, "raw": user_text}
791
+ return "Did you mean A or B? Pick one, then click Use selection.", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), state_dump(st2), ""
792
 
793
  if res.get("mode") != "ok":
794
+ return "Not found.", gr.update(visible=False), gr.update(visible=False), "{}", ""
795
 
796
  life_row = df_eos.iloc[int(res["row_idx"])]
797
  eos, eol, status = row_to_dates_and_status(life_row)
798
 
799
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
800
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
801
+ mimo = infer_mimo_for_5g(repl.get("repl_5g",""), canon_make)
802
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
803
+ ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
804
 
805
+ output = assemble_output(life_row, status, eos, eol, repl, ant)
806
+ st_out = {"row_idx": int(res["row_idx"]), "repl": repl, "ant": ant, "raw": user_text}
807
+ return output, gr.update(visible=False), gr.update(visible=False), state_dump(st_out), ""
808
 
809
+ def use_selection(selected_label: str, st_json: str):
810
+ st = state_load(st_json)
811
  if not st or st.get("mode") != "pick":
812
+ return "Run a search first.", gr.update(visible=False), gr.update(visible=False), "{}", ""
813
+
814
  if not selected_label:
815
+ return "Pick A or B first.", gr.update(visible=True), gr.update(visible=True), st_json, ""
816
 
817
  chosen_row = None
818
  for o in st.get("options", []):
 
820
  chosen_row = int(o["row_idx"])
821
  break
822
  if chosen_row is None:
823
+ return "Pick a valid option.", gr.update(visible=True), gr.update(visible=True), st_json, ""
824
 
825
  life_row = df_eos.iloc[int(chosen_row)]
826
  eos, eol, status = row_to_dates_and_status(life_row)
 
 
 
 
827
 
828
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
829
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
830
+ mimo = infer_mimo_for_5g(repl.get("repl_5g",""), canon_make)
831
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
832
+ ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
833
+
834
+ output = assemble_output(life_row, status, eos, eol, repl, ant)
835
+ st_out = {"row_idx": int(chosen_row), "repl": repl, "ant": ant, "raw": st.get("raw","")}
836
+ return output, gr.update(visible=False), gr.update(visible=False), state_dump(st_out), ""
837
+
838
+ def make_install_ready(st_json: str):
839
+ st = state_load(st_json)
840
+ if not st or "row_idx" not in st:
841
+ return "Run a lookup first."
842
+ life_row = df_eos.iloc[int(st["row_idx"])]
843
+ current_sku = str(life_row.get("sku","") or "")
844
+ return install_ready_checklist(current_sku, st.get("repl", {}) or {}, st.get("ant", {}) or {})
845
 
846
+
847
+ # ============================
848
+ # UI
849
+ # ============================
850
  with gr.Blocks(title="Only-Routers") as demo:
851
+ gr.Markdown("## Only-Routers\nSingle lookup + Batch upload for Verizon reps.")
852
+
853
+ with gr.Tabs():
854
+ with gr.Tab("Single"):
855
+ user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
856
+ st = gr.State("{}") # JSON string
857
+
858
+ check_btn = gr.Button("Check", variant="primary")
859
+ pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
860
+ use_btn = gr.Button("Use selection", visible=False)
861
+
862
+ output_md = gr.Markdown()
863
+
864
+ install_btn = gr.Button("Make install-ready checklist")
865
+ install_md = gr.Markdown()
866
+
867
+ check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st, install_md], api_name=False)
868
+ use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st, install_md], api_name=False)
869
+ install_btn.click(fn=make_install_ready, inputs=[st], outputs=[install_md], api_name=False)
870
 
871
+ with gr.Tab("Batch"):
872
+ gr.Markdown("Paste one per line or upload a CSV (first column). Batch runs fast (no GPT).")
873
+ batch_text = gr.Textbox(label="Paste devices (one per line)", lines=8, placeholder="WR21\nRUT240\nIBR650B")
874
+ batch_file = gr.File(label="Upload CSV", file_types=[".csv"])
875
+ include_ant = gr.Checkbox(label="Include antenna picks (slower)", value=False)
876
+ run_btn = gr.Button("Run batch", variant="primary")
877
 
878
+ summary_md = gr.Markdown()
879
+ rollup_md = gr.Markdown()
880
+ table = gr.Dataframe(interactive=False, wrap=True)
881
+ dl = gr.File(label="Download results CSV")
882
 
883
+ run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md], api_name=False)
 
884
 
885
+ # IMPORTANT: On Spaces, demo.launch() is correct; do NOT use share=True.
886
+ demo.launch(show_api=False)
Updates/only-routers_ai_poc_hf_fixed_v6.ipynb ADDED
@@ -0,0 +1,1039 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "0fa1b661",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Only-Routers (HF fixed v6)\n",
9
+ "\n",
10
+ "Updated for new simplified routers_eos_eol_by_sku.csv columns."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "4f5c8d78",
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "import os\n",
21
+ "import re\n",
22
+ "import json\n",
23
+ "import math\n",
24
+ "import hashlib\n",
25
+ "import tempfile\n",
26
+ "from dataclasses import dataclass\n",
27
+ "from datetime import datetime, date\n",
28
+ "from typing import Any, Dict, List, Optional, Tuple\n",
29
+ "\n",
30
+ "import numpy as np\n",
31
+ "import pandas as pd\n",
32
+ "\n",
33
+ "import fitz # PyMuPDF\n",
34
+ "import faiss\n",
35
+ "from sentence_transformers import SentenceTransformer\n",
36
+ "from rapidfuzz import fuzz, process\n",
37
+ "\n",
38
+ "import gradio as gr\n",
39
+ "from openai import OpenAI\n",
40
+ "\n",
41
+ "\n",
42
+ "# ============================\n",
43
+ "# Settings\n",
44
+ "# ============================\n",
45
+ "TODAY = date(2026, 1, 18)\n",
46
+ "OPENAI_MODEL = \"gpt-5.2\"\n",
47
+ "OPENAI_REASONING = {\"effort\": \"high\"}\n",
48
+ "MATCH_OK = 80\n",
49
+ "\n",
50
+ "EMBED_MODEL_NAME = \"sentence-transformers/all-MiniLM-L6-v2\"\n",
51
+ "PARSEC_CONTEXT_BEFORE = 900\n",
52
+ "PARSEC_CONTEXT_AFTER = 1600\n",
53
+ "\n",
54
+ "\n",
55
+ "# ============================\n",
56
+ "# OpenAI client (HF Space secret: OPENAI_API_KEY)\n",
57
+ "# ============================\n",
58
+ "API_KEY = os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n",
59
+ "client = OpenAI(api_key=API_KEY) if API_KEY else None\n",
60
+ "\n",
61
+ "# ----------------------------\n",
62
+ "# Gradio state helpers\n",
63
+ "# Keep state as a JSON STRING to avoid schema issues on Hugging Face.\n",
64
+ "# ----------------------------\n",
65
+ "def state_load(st_json: str) -> Dict[str, Any]:\n",
66
+ " try:\n",
67
+ " if not st_json:\n",
68
+ " return {}\n",
69
+ " return json.loads(st_json) if isinstance(st_json, str) else {}\n",
70
+ " except Exception:\n",
71
+ " return {}\n",
72
+ "\n",
73
+ "def state_dump(st: Dict[str, Any]) -> str:\n",
74
+ " try:\n",
75
+ " return json.dumps(st or {}, ensure_ascii=False)\n",
76
+ " except Exception:\n",
77
+ " return \"{}\"\n",
78
+ "\n",
79
+ "\n",
80
+ "\n",
81
+ "# ============================\n",
82
+ "# Helpers\n",
83
+ "# ============================\n",
84
+ "def norm_text(s: Any) -> str:\n",
85
+ " try:\n",
86
+ " if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):\n",
87
+ " return \"\"\n",
88
+ " except Exception:\n",
89
+ " pass\n",
90
+ " s = str(s).strip().lower()\n",
91
+ " s = re.sub(r\"[^a-z0-9\\s\\-\\/]\", \" \", s)\n",
92
+ " s = re.sub(r\"\\s+\", \" \", s).strip()\n",
93
+ " return s\n",
94
+ "\n",
95
+ "def safe_str(v: Any) -> str:\n",
96
+ " if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):\n",
97
+ " return \"\"\n",
98
+ " return str(v).strip()\n",
99
+ "\n",
100
+ "def is_5g(modem_type: Any) -> bool:\n",
101
+ " s = norm_text(modem_type)\n",
102
+ " return (\"5g\" in s) or (\"nr\" in s)\n",
103
+ "\n",
104
+ "def json_load_safe(s: str) -> Dict[str, Any]:\n",
105
+ " try:\n",
106
+ " return json.loads(s)\n",
107
+ " except Exception:\n",
108
+ " return {}\n",
109
+ "\n",
110
+ "def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 600) -> Dict[str, Any]:\n",
111
+ " if client is None:\n",
112
+ " return {}\n",
113
+ " resp = client.responses.create(\n",
114
+ " model=OPENAI_MODEL,\n",
115
+ " reasoning=OPENAI_REASONING,\n",
116
+ " input=[{\"role\":\"system\",\"content\":system},{\"role\":\"user\",\"content\":json.dumps(payload)}],\n",
117
+ " max_output_tokens=max_tokens,\n",
118
+ " )\n",
119
+ " return json_load_safe(getattr(resp, \"output_text\", \"\") or \"\")\n",
120
+ "\n",
121
+ "\n",
122
+ "# ============================\n",
123
+ "# Load data\n",
124
+ "# ============================\n",
125
+ "EOS_PATH = \"routers_eos_eol_by_sku.csv\"\n",
126
+ "DEC_PATH = \"dec2025routers.csv\"\n",
127
+ "PARSEC_PDF = \"ParsecCatalog.pdf\"\n",
128
+ "\n",
129
+ "if not os.path.exists(EOS_PATH):\n",
130
+ " raise FileNotFoundError(f\"Missing {EOS_PATH} in repo.\")\n",
131
+ "if not os.path.exists(DEC_PATH):\n",
132
+ " raise FileNotFoundError(f\"Missing {DEC_PATH} in repo.\")\n",
133
+ "if not os.path.exists(PARSEC_PDF):\n",
134
+ " raise FileNotFoundError(f\"Missing {PARSEC_PDF} in repo.\")\n",
135
+ "\n",
136
+ "df_eos = pd.read_csv(EOS_PATH).copy()\n",
137
+ "df_dec = pd.read_csv(DEC_PATH).copy()\n",
138
+ "\n",
139
+ "\n",
140
+ "def _canonize_eos_columns(df: pd.DataFrame) -> pd.DataFrame:\n",
141
+ " \"\"\"Normalize lifecycle CSV column names (case-insensitive) and create expected columns.\"\"\"\n",
142
+ " # Map various header spellings to canonical names used by the app\n",
143
+ " mapping = {}\n",
144
+ " for c in df.columns:\n",
145
+ " k = str(c).strip().lower().replace(\" \", \"_\")\n",
146
+ " if k in {\"sku\", \"model\", \"device\", \"device_sku\"}:\n",
147
+ " mapping[c] = \"sku\"\n",
148
+ " elif k in {\"manufacturer\", \"make\", \"vendor\"}:\n",
149
+ " mapping[c] = \"manufacturer\"\n",
150
+ " elif k in {\"device_type\", \"type\"}:\n",
151
+ " mapping[c] = \"device_type\"\n",
152
+ " elif k in {\"end_of_sale\", \"eos\", \"end_sale\", \"end_of_sales\"}:\n",
153
+ " mapping[c] = \"end_of_sale\"\n",
154
+ " elif k in {\"end_of_life\", \"eol\", \"end_life\"}:\n",
155
+ " mapping[c] = \"end_of_life\"\n",
156
+ " elif k in {\"suggested_replacement\", \"replacement_4g\", \"lte_replacement\", \"replacement_lte\", \"replacement\"}:\n",
157
+ " mapping[c] = \"suggested_replacement\"\n",
158
+ " elif k in {\"advanced_5g_option\", \"replacement_5g\", \"fiveg_replacement\", \"5g_replacement\", \"upgrade_5g\"}:\n",
159
+ " mapping[c] = \"advanced_5g_option\"\n",
160
+ " elif k in {\"region\", \"market\"}:\n",
161
+ " mapping[c] = \"region\"\n",
162
+ " elif k in {\"notes\", \"note\"}:\n",
163
+ " mapping[c] = \"notes\"\n",
164
+ " elif k in {\"description\", \"device_description\", \"name\"}:\n",
165
+ " mapping[c] = \"description\"\n",
166
+ "\n",
167
+ " df = df.rename(columns=mapping).copy()\n",
168
+ "\n",
169
+ " # Create expected columns if missing\n",
170
+ " if \"sku\" not in df.columns:\n",
171
+ " # Try the common capitalized header as a fallback\n",
172
+ " if \"SKU\" in df.columns:\n",
173
+ " df[\"sku\"] = df[\"SKU\"].astype(str)\n",
174
+ " else:\n",
175
+ " df[\"sku\"] = \"\"\n",
176
+ "\n",
177
+ " if \"manufacturer\" not in df.columns:\n",
178
+ " df[\"manufacturer\"] = \"\"\n",
179
+ "\n",
180
+ " if \"device_type\" not in df.columns:\n",
181
+ " df[\"device_type\"] = \"\"\n",
182
+ "\n",
183
+ " if \"description\" not in df.columns:\n",
184
+ " # If the simplified file removed description, use SKU as description (still searchable)\n",
185
+ " df[\"description\"] = df[\"sku\"].astype(str)\n",
186
+ "\n",
187
+ " if \"notes\" not in df.columns:\n",
188
+ " df[\"notes\"] = \"\"\n",
189
+ "\n",
190
+ " if \"region\" not in df.columns:\n",
191
+ " df[\"region\"] = \"\"\n",
192
+ "\n",
193
+ " if \"suggested_replacement\" not in df.columns:\n",
194
+ " df[\"suggested_replacement\"] = \"\"\n",
195
+ "\n",
196
+ " if \"advanced_5g_option\" not in df.columns:\n",
197
+ " df[\"advanced_5g_option\"] = \"\"\n",
198
+ "\n",
199
+ " if \"end_of_sale\" not in df.columns:\n",
200
+ " df[\"end_of_sale\"] = \"\"\n",
201
+ "\n",
202
+ " if \"end_of_life\" not in df.columns:\n",
203
+ " df[\"end_of_life\"] = \"\"\n",
204
+ "\n",
205
+ " return df\n",
206
+ "\n",
207
+ "df_eos = _canonize_eos_columns(df_eos)\n",
208
+ "\n",
209
+ "\n",
210
+ "def region_ok(x: Any) -> bool:\n",
211
+ " s = str(x or \"\").strip().lower()\n",
212
+ " if not s:\n",
213
+ " return True\n",
214
+ " if \"not specified\" in s:\n",
215
+ " return True\n",
216
+ " if \"north america\" in s:\n",
217
+ " return True\n",
218
+ " if re.search(r\"\\busa\\b\", s):\n",
219
+ " return True\n",
220
+ " if re.search(r\"\\bunited\\s+states\\b\", s):\n",
221
+ " return True\n",
222
+ " if re.search(r\"\\bu\\.?s\\.?\\b\", s):\n",
223
+ " return True\n",
224
+ " return False\n",
225
+ "\n",
226
+ "if \"region\" in df_eos.columns:\n",
227
+ " df_eos = df_eos[df_eos[\"region\"].apply(region_ok)].reset_index(drop=True)\n",
228
+ "\n",
229
+ "# Maker mapping (includes Teltonika)\n",
230
+ "CANON_MAKER = {\n",
231
+ " \"CRADLEPOINT\": {\"cradlepoint\", \"ericsson\", \"ericsson enterprise wireless\"},\n",
232
+ " \"SIERRA\": {\"sierra\", \"sierra wireless\", \"semtech\", \"airlink\"},\n",
233
+ " \"FEENEY\": {\"feeney\", \"feeney wireless\", \"inseego\"},\n",
234
+ " \"DIGI\": {\"digi\", \"accelerated\", \"accelerated concepts\"},\n",
235
+ " \"CISCO_MERAKI\": {\"meraki\", \"cisco meraki\"},\n",
236
+ " \"CISCO\": {\"cisco\"},\n",
237
+ " \"TELTONIKA\": {\"teltonika\"},\n",
238
+ "}\n",
239
+ "\n",
240
+ "def canon_maker_from_text(s: Any) -> str:\n",
241
+ " t = norm_text(s)\n",
242
+ " for canon, terms in CANON_MAKER.items():\n",
243
+ " for term in terms:\n",
244
+ " if term in t:\n",
245
+ " return canon\n",
246
+ " return \"UNKNOWN\"\n",
247
+ "\n",
248
+ "df_eos[\"_canon_make\"] = df_eos[\"manufacturer\"].apply(canon_maker_from_text) if \"manufacturer\" in df_eos.columns else \"UNKNOWN\"\n",
249
+ "df_eos[\"_norm_sku\"] = df_eos[\"sku\"].apply(norm_text) if \"sku\" in df_eos.columns else \"\"\n",
250
+ "df_eos[\"_norm_desc\"] = df_eos[\"description\"].apply(norm_text) if \"description\" in df_eos.columns else \"\"\n",
251
+ "df_eos[\"_norm_notes\"] = df_eos[\"notes\"].apply(norm_text) if \"notes\" in df_eos.columns else \"\"\n",
252
+ "\n",
253
+ "df_dec[\"_canon_make\"] = df_dec[\"Make\"].apply(canon_maker_from_text) if \"Make\" in df_dec.columns else \"UNKNOWN\"\n",
254
+ "df_dec[\"_norm_model\"] = df_dec[\"Model\"].apply(norm_text) if \"Model\" in df_dec.columns else \"\"\n",
255
+ "df_dec[\"_is5g\"] = df_dec[\"Modem Type\"].apply(is_5g) if \"Modem Type\" in df_dec.columns else False\n",
256
+ "\n",
257
+ "\n",
258
+ "# ============================\n",
259
+ "# Date helpers\n",
260
+ "# ============================\n",
261
+ "@dataclass\n",
262
+ "class ParsedDate:\n",
263
+ " raw: str\n",
264
+ " kind: str\n",
265
+ " value: Optional[date]\n",
266
+ "\n",
267
+ "def parse_date_field(x: Any) -> ParsedDate:\n",
268
+ " raw = str(x or \"\").strip()\n",
269
+ " if not raw:\n",
270
+ " return ParsedDate(raw=\"\", kind=\"missing\", value=None)\n",
271
+ "\n",
272
+ " # Common US formats: M/D/YY or M/D/YYYY (e.g., 6/24/24, 9/30/21)\n",
273
+ " for fmt in (\"%m/%d/%y\", \"%m/%d/%Y\", \"%-m/%-d/%y\", \"%-m/%-d/%Y\"):\n",
274
+ " try:\n",
275
+ " dt = datetime.strptime(raw, fmt).date()\n",
276
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
277
+ " except Exception:\n",
278
+ " pass\n",
279
+ "\n",
280
+ " # ISO-ish: YYYY\n",
281
+ " if re.fullmatch(r\"\\d{4}\", raw):\n",
282
+ " y = int(raw)\n",
283
+ " if y == TODAY.year:\n",
284
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
285
+ " if y < TODAY.year:\n",
286
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
287
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 12, 31))\n",
288
+ "\n",
289
+ " # YYYY-MM\n",
290
+ " if re.fullmatch(r\"\\d{4}-\\d{2}\", raw):\n",
291
+ " try:\n",
292
+ " y, m = raw.split(\"-\")\n",
293
+ " return ParsedDate(raw=raw, kind=\"year_month\", value=date(int(y), int(m), 1))\n",
294
+ " except Exception:\n",
295
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
296
+ "\n",
297
+ " # YYYY-MM-DD\n",
298
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
299
+ " try:\n",
300
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
301
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
302
+ " except Exception:\n",
303
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
304
+ "\n",
305
+ " # Last resort: leave as raw (unparsed)\n",
306
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
307
+ "\n",
308
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
309
+ " try:\n",
310
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
311
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
312
+ " except Exception:\n",
313
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
314
+ "\n",
315
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
316
+ "\n",
317
+ "def display_date(pd_: ParsedDate) -> str:\n",
318
+ " if pd_.kind == \"missing\":\n",
319
+ " return \"Not listed\"\n",
320
+ " if pd_.kind == \"bad\":\n",
321
+ " return pd_.raw or \"Not listed\"\n",
322
+ " return pd_.raw\n",
323
+ "\n",
324
+ "def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:\n",
325
+ " if eos.value is None and eol.value is None:\n",
326
+ " return \"Unknown\"\n",
327
+ " if eol.value is not None and eol.value <= TODAY:\n",
328
+ " return \"End of Life\"\n",
329
+ " if eos.value is not None and eos.value <= TODAY:\n",
330
+ " return \"End of Sale\"\n",
331
+ " return \"Active\"\n",
332
+ "\n",
333
+ "def row_to_dates_and_status(row: pd.Series) -> Tuple[str, str, str]:\n",
334
+ " eos = parse_date_field(row.get(\"end_of_sale\"))\n",
335
+ " eol = parse_date_field(row.get(\"end_of_life\"))\n",
336
+ " return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)\n",
337
+ "\n",
338
+ "\n",
339
+ "# ============================\n",
340
+ "# Embeddings + Parsec index\n",
341
+ "# ============================\n",
342
+ "embedder = SentenceTransformer(EMBED_MODEL_NAME)\n",
343
+ "\n",
344
+ "def extract_pdf_text_pages(path: str) -> List[str]:\n",
345
+ " doc = fitz.open(path)\n",
346
+ " return [doc[i].get_text(\"text\") for i in range(len(doc))]\n",
347
+ "\n",
348
+ "def build_parsec_cards(pages: List[str]) -> List[str]:\n",
349
+ " cards = []\n",
350
+ " for p in pages:\n",
351
+ " for m in re.finditer(r\"Standard\\s+SKU:\", p):\n",
352
+ " start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)\n",
353
+ " end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)\n",
354
+ " c = p[start:end].strip()\n",
355
+ " if len(c) >= 200:\n",
356
+ " cards.append(c)\n",
357
+ " out, seen = [], set()\n",
358
+ " for c in cards:\n",
359
+ " h = hashlib.sha1(c.encode(\"utf-8\")).hexdigest()\n",
360
+ " if h not in seen:\n",
361
+ " seen.add(h); out.append(c)\n",
362
+ " return out\n",
363
+ "\n",
364
+ "parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))\n",
365
+ "parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)\n",
366
+ "parsec_emb = np.asarray(parsec_emb, dtype=np.float32)\n",
367
+ "parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])\n",
368
+ "parsec_index.add(parsec_emb)\n",
369
+ "\n",
370
+ "\n",
371
+ "# ============================\n",
372
+ "# Device resolution\n",
373
+ "# ============================\n",
374
+ "def label_for_row(i: int) -> str:\n",
375
+ " r = df_eos.iloc[i]\n",
376
+ " return f\"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}\"[:220]\n",
377
+ "\n",
378
+ "EOS_LABELS = [label_for_row(i) for i in range(len(df_eos))]\n",
379
+ "EOS_CORPUS = []\n",
380
+ "for _, r in df_eos.iterrows():\n",
381
+ " EOS_CORPUS.append(\" \".join([r.get(\"_norm_sku\",\"\"), r.get(\"_canon_make\",\"\"), r.get(\"_norm_desc\",\"\"), r.get(\"_norm_notes\",\"\")]))\n",
382
+ "\n",
383
+ "def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int, int, str]]:\n",
384
+ " q = norm_text(query)\n",
385
+ " hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)\n",
386
+ " return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]\n",
387
+ "\n",
388
+ "def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:\n",
389
+ " if client is None:\n",
390
+ " return {}\n",
391
+ " sys = \"Pick which router the user meant. Never invent. Return strict JSON only.\"\n",
392
+ " payload = {\n",
393
+ " \"user_input\": user_text,\n",
394
+ " \"candidates\": [{\"row_idx\": i, \"score\": s, \"label\": lbl} for (i,s,lbl) in candidates],\n",
395
+ " \"rules\": [\n",
396
+ " \"If one is clearly correct, return mode='ok' with row_idx.\",\n",
397
+ " \"If two are plausible, return mode='pick' with top 2 options.\"\n",
398
+ " ],\n",
399
+ " \"output_schema\": {\"mode\":\"ok|pick\",\"row_idx\":\"int\",\"options\":[{\"row_idx\":\"int\",\"label\":\"string\"}]}\n",
400
+ " }\n",
401
+ " return gpt_json(sys, payload, max_tokens=280)\n",
402
+ "\n",
403
+ "def resolve_device(user_text: str) -> Dict[str, Any]:\n",
404
+ " q = norm_text(user_text)\n",
405
+ " exact = df_eos.index[df_eos[\"_norm_sku\"] == q].tolist()\n",
406
+ " if len(exact) == 1:\n",
407
+ " return {\"mode\":\"ok\",\"row_idx\": int(exact[0])}\n",
408
+ " if len(exact) > 1:\n",
409
+ " opts = [{\"row_idx\": int(i), \"label\": EOS_LABELS[int(i)]} for i in exact[:2]]\n",
410
+ " return {\"mode\":\"pick\",\"options\": opts}\n",
411
+ "\n",
412
+ " cands = local_candidates(user_text, top_k=6)\n",
413
+ " if not cands:\n",
414
+ " return {\"mode\":\"not_found\"}\n",
415
+ "\n",
416
+ " if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):\n",
417
+ " return {\"mode\":\"ok\",\"row_idx\": cands[0][0]}\n",
418
+ "\n",
419
+ " g = gpt_choose_device(user_text, cands)\n",
420
+ " if g.get(\"mode\") == \"ok\" and isinstance(g.get(\"row_idx\"), int):\n",
421
+ " return {\"mode\":\"ok\",\"row_idx\": int(g[\"row_idx\"])}\n",
422
+ "\n",
423
+ " if g.get(\"mode\") == \"pick\":\n",
424
+ " opts = g.get(\"options\", []) or []\n",
425
+ " opts2 = [{\"row_idx\": int(o[\"row_idx\"]), \"label\": str(o[\"label\"])} for o in opts[:2] if \"row_idx\" in o]\n",
426
+ " if opts2:\n",
427
+ " return {\"mode\":\"pick\",\"options\": opts2}\n",
428
+ "\n",
429
+ " if len(cands) > 1:\n",
430
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]},{\"row_idx\":cands[1][0],\"label\":cands[1][2]}]}\n",
431
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]}]}\n",
432
+ "\n",
433
+ "\n",
434
+ "# ============================\n",
435
+ "# Replacements — lifecycle CSV source of truth\n",
436
+ "# ============================\n",
437
+ "def extract_model_token(text: str) -> str:\n",
438
+ " s = safe_str(text)\n",
439
+ " if not s:\n",
440
+ " return \"\"\n",
441
+ " parts = [p.strip() for p in s.split(\"|\") if p.strip()]\n",
442
+ " candidates = parts[::-1] if parts else [s]\n",
443
+ " for cand in candidates:\n",
444
+ " m = re.search(r\"\\bRUT[A-Z]?\\d{2,4}\\b\", cand.upper())\n",
445
+ " if m:\n",
446
+ " return m.group(0).upper()\n",
447
+ " m = re.search(r\"\\bIX\\d{2}\\b\", cand, flags=re.IGNORECASE)\n",
448
+ " if m:\n",
449
+ " return m.group(0).upper()\n",
450
+ " m = re.search(r\"\\b(R\\d{3,4}|E\\d{3,4}|S\\d{3,4})\\b\", cand, flags=re.IGNORECASE)\n",
451
+ " if m:\n",
452
+ " return m.group(0).upper()\n",
453
+ " m = re.search(r\"\\b[A-Z]{1,6}\\d{2,4}[A-Z]?\\b\", cand.upper())\n",
454
+ " if m:\n",
455
+ " return m.group(0).upper()\n",
456
+ " return candidates[0][:60]\n",
457
+ "\n",
458
+ "def device_is_4g(row: pd.Series) -> bool:\n",
459
+ " # Detect LTE/4G even when the description uses \"Cat 4 / Cat6 / Cat 12\" without saying \"LTE\"\n",
460
+ " t = norm_text(row.get(\"description\",\"\")) + \" \" + norm_text(row.get(\"notes\",\"\")) + \" \" + norm_text(row.get(\"sku\",\"\"))\n",
461
+ "\n",
462
+ " # If it explicitly says 5G/NR, treat as not 4G-only\n",
463
+ " if (\"5g\" in t) or (\"nr\" in t):\n",
464
+ " return False\n",
465
+ "\n",
466
+ " # Classic signals\n",
467
+ " if (\"lte\" in t) or (\"4g\" in t):\n",
468
+ " return True\n",
469
+ "\n",
470
+ " # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)\n",
471
+ " if re.search(r\"\\bcat\\s*[-]?\\s*(m1|m2)\\b\", t):\n",
472
+ " return True\n",
473
+ "\n",
474
+ " m = re.search(r\"\\bcat\\s*[-]?\\s*(\\d{1,2})\\b\", t)\n",
475
+ " if m:\n",
476
+ " try:\n",
477
+ " cat = int(m.group(1))\n",
478
+ " if 0 < cat <= 20:\n",
479
+ " return True\n",
480
+ " except Exception:\n",
481
+ " pass\n",
482
+ "\n",
483
+ " # If \"cat\" appears at all, it's almost always LTE-family\n",
484
+ " if \"cat\" in t:\n",
485
+ " return True\n",
486
+ "\n",
487
+ " return False\n",
488
+ "\n",
489
+ " # If it explicitly says 5G/NR, treat as not 4G-only\n",
490
+ " if (\"5g\" in t) or (\"nr\" in t):\n",
491
+ " return False\n",
492
+ "\n",
493
+ " # Classic signals\n",
494
+ " if (\"lte\" in t) or (\"4g\" in t):\n",
495
+ " return True\n",
496
+ "\n",
497
+ " # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)\n",
498
+ " if re.search(r\"\\bcat\\s*[-]?\\s*(m1|m2)\\b\", t):\n",
499
+ " return True\n",
500
+ "\n",
501
+ " m = re.search(r\"\\bcat\\s*[-]?\\s*(\\d{1,2})\\b\", t)\n",
502
+ " if m:\n",
503
+ " try:\n",
504
+ " cat = int(m.group(1))\n",
505
+ " if 0 < cat <= 20:\n",
506
+ " return True\n",
507
+ " except Exception:\n",
508
+ " pass\n",
509
+ "\n",
510
+ " # If \"cat\" appears at all, it's almost always LTE-family\n",
511
+ " if \"cat\" in t:\n",
512
+ " return True\n",
513
+ "\n",
514
+ " return False\n",
515
+ "\n",
516
+ "\n",
517
+ "def candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
518
+ " mfr = norm_text(manufacturer)\n",
519
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
520
+ " vals = pool[\"advanced_5g_option\"].tolist() if \"advanced_5g_option\" in pool.columns else []\n",
521
+ " out, seen = [], set()\n",
522
+ " for v in vals:\n",
523
+ " tok = extract_model_token(v)\n",
524
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
525
+ " seen.add(tok); out.append(tok)\n",
526
+ " return out\n",
527
+ "\n",
528
+ "def candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
529
+ " mfr = norm_text(manufacturer)\n",
530
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
531
+ " vals = pool[\"suggested_replacement\"].tolist() if \"suggested_replacement\" in pool.columns else []\n",
532
+ " out, seen = [], set()\n",
533
+ " for v in vals:\n",
534
+ " tok = extract_model_token(v)\n",
535
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
536
+ " seen.add(tok); out.append(tok)\n",
537
+ " return out\n",
538
+ "\n",
539
+ "def gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:\n",
540
+ " if client is None or not candidates:\n",
541
+ " return \"\"\n",
542
+ " sys = \"Pick the best replacement model. Choose only from candidates. Return strict JSON only.\"\n",
543
+ " payload = {\n",
544
+ " \"old_device\": {\n",
545
+ " \"sku\": str(old_row.get(\"sku\",\"\")),\n",
546
+ " \"manufacturer\": str(old_row.get(\"manufacturer\",\"\")),\n",
547
+ " \"description\": str(old_row.get(\"description\",\"\")),\n",
548
+ " \"need\": need,\n",
549
+ " },\n",
550
+ " \"candidates\": candidates[:40],\n",
551
+ " \"output_schema\": {\"choice\":\"string\"}\n",
552
+ " }\n",
553
+ " out = gpt_json(sys, payload, max_tokens=240) or {}\n",
554
+ " choice = str(out.get(\"choice\",\"\") or \"\").strip()\n",
555
+ " return choice if choice in candidates else \"\"\n",
556
+ "\n",
557
+ "def fallback_5g_from_dec(canon_make: str) -> str:\n",
558
+ " pool5 = df_dec[(df_dec[\"_canon_make\"] == canon_make) & (df_dec[\"_is5g\"] == True)]\n",
559
+ " return str(pool5.iloc[0][\"Model\"]).strip() if not pool5.empty else \"\"\n",
560
+ "\n",
561
+ "def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:\n",
562
+ " canon = str(row.get(\"_canon_make\",\"UNKNOWN\"))\n",
563
+ " manufacturer = str(row.get(\"manufacturer\",\"\") or \"\")\n",
564
+ "\n",
565
+ " sug_raw = safe_str(row.get(\"suggested_replacement\",\"\"))\n",
566
+ " adv_raw = safe_str(row.get(\"advanced_5g_option\",\"\"))\n",
567
+ "\n",
568
+ " has_4g_alt = bool(sug_raw.strip())\n",
569
+ " has_5g_alt = bool(adv_raw.strip())\n",
570
+ "\n",
571
+ " # Treat as 4G if the description indicates LTE OR lifecycle provides a 4G suggested replacement\n",
572
+ " is_4g = device_is_4g(row) or has_4g_alt\n",
573
+ "\n",
574
+ " # Provide 5G option if the unit is 4G, EOS/EOL, or lifecycle explicitly provides advanced_5g_option\n",
575
+ " want_5g = is_4g or (status in {\"End of Sale\",\"End of Life\"}) or has_5g_alt\n",
576
+ "\n",
577
+ " # 4G alternative: show whenever lifecycle provides it (or device appears 4G)\n",
578
+ " repl_4g = \"Not applicable\"\n",
579
+ " if is_4g or has_4g_alt:\n",
580
+ " repl_4g = extract_model_token(sug_raw)\n",
581
+ " if not repl_4g:\n",
582
+ " cand4 = candidate_4g_models_from_lifecycle(manufacturer)\n",
583
+ " repl_4g = (gpt_pick_from_candidates(row, cand4, \"4G alternative\") if (use_gpt and client) else \"\") or (cand4[0] if cand4 else \"\")\n",
584
+ " if not repl_4g:\n",
585
+ " repl_4g = \"Not applicable\"\n",
586
+ "\n",
587
+ " # 5G replacement: prefer lifecycle advanced_5g_option whenever present\n",
588
+ " repl_5g = \"Not listed\"\n",
589
+ " if want_5g:\n",
590
+ " repl_5g = extract_model_token(adv_raw)\n",
591
+ " if not repl_5g:\n",
592
+ " cand5 = candidate_5g_models_from_lifecycle(manufacturer)\n",
593
+ " repl_5g = (gpt_pick_from_candidates(row, cand5, \"5G replacement/upgrade\") if (use_gpt and client) else \"\") or (cand5[0] if cand5 else \"\")\n",
594
+ " if not repl_5g:\n",
595
+ " repl_5g = fallback_5g_from_dec(canon) or \"Not listed\"\n",
596
+ "\n",
597
+ " if repl_5g.lower() == \"nan\":\n",
598
+ " repl_5g = \"Not listed\"\n",
599
+ "\n",
600
+ " return {\"repl_4g\": repl_4g, \"repl_5g\": repl_5g, \"sources\": [\"lifecycle_csv\"] + ([\"gpt\"] if (use_gpt and client) else [])}\n",
601
+ "\n",
602
+ "\n",
603
+ "# ============================\n",
604
+ "# Antennas (Parsec-only)\n",
605
+ "# ============================\n",
606
+ "PARSEC_FAMILY_WORDS = {\"chinook\",\"labrador\",\"boxer\",\"bloodhound\",\"husky\",\"beagle\",\"mastiff\",\"collie\",\"shepherd\",\"belgian\",\"australian\",\"terrier\",\"pyrenees\"}\n",
607
+ "BAD_NAME_MARKERS = {\"customization\",\"standard connectors\",\"connectors\",\"features\",\"benefits\",\"specifications\",\"mechanical\",\"electrical\",\"mounting\",\"accessories\",\"description:\",\"standard sku\"}\n",
608
+ "\n",
609
+ "def clean_line(s: str) -> str:\n",
610
+ " s = re.sub(r\"\\s+\", \" \", str(s or \"\").strip())\n",
611
+ " if re.fullmatch(r\"-[a-z0-9]+\", s.lower()):\n",
612
+ " return \"\"\n",
613
+ " return s\n",
614
+ "\n",
615
+ "def is_bad_name_line(line: str) -> bool:\n",
616
+ " low = line.lower()\n",
617
+ " if any(m in low for m in BAD_NAME_MARKERS):\n",
618
+ " return True\n",
619
+ " if re.search(r\"\\b-[a-z0-9]{1,4}\\b\", low) and len(low) <= 25:\n",
620
+ " return True\n",
621
+ " return False\n",
622
+ "\n",
623
+ "def family_from_line(line: str) -> str:\n",
624
+ " low = line.lower()\n",
625
+ " for fam in PARSEC_FAMILY_WORDS:\n",
626
+ " if fam in low:\n",
627
+ " return fam.capitalize()\n",
628
+ " return \"\"\n",
629
+ "\n",
630
+ "def parsec_connectors_from_card(t: str) -> str:\n",
631
+ " m = re.search(r\"Standard\\s+Connectors:\\s*(.+)\", t, flags=re.IGNORECASE)\n",
632
+ " if m:\n",
633
+ " return re.sub(r\"\\s+\", \" \", m.group(1).strip())[:80]\n",
634
+ " return \"\"\n",
635
+ "\n",
636
+ "def parsec_mounts_from_card(t: str) -> List[str]:\n",
637
+ " mounts = []\n",
638
+ " for m in re.finditer(r\"Mount:\\s*(.+)\", t, flags=re.IGNORECASE):\n",
639
+ " val = re.sub(r\"\\s+\", \" \", m.group(1).strip())\n",
640
+ " parts = [p.strip().lower() for p in val.split(\",\") if p.strip()]\n",
641
+ " mounts.extend(parts)\n",
642
+ " out = []\n",
643
+ " seen = set()\n",
644
+ " for x in mounts:\n",
645
+ " if x not in seen:\n",
646
+ " seen.add(x); out.append(x)\n",
647
+ " return out\n",
648
+ "\n",
649
+ "def parsec_name_from_card(card_text: str) -> str:\n",
650
+ " lines = [clean_line(ln) for ln in str(card_text or \"\").splitlines()]\n",
651
+ " lines = [ln for ln in lines if ln]\n",
652
+ "\n",
653
+ " for ln in lines:\n",
654
+ " if is_bad_name_line(ln):\n",
655
+ " continue\n",
656
+ " fam = family_from_line(ln)\n",
657
+ " if fam:\n",
658
+ " return fam\n",
659
+ "\n",
660
+ " sku_i = None\n",
661
+ " for i, ln in enumerate(lines):\n",
662
+ " if \"standard sku\" in ln.lower():\n",
663
+ " sku_i = i\n",
664
+ " break\n",
665
+ " if sku_i is not None:\n",
666
+ " window = lines[max(0, sku_i - 12):sku_i]\n",
667
+ " for ln in reversed(window):\n",
668
+ " if is_bad_name_line(ln):\n",
669
+ " continue\n",
670
+ " if 3 <= len(ln) <= 40 and re.search(r\"[A-Za-z]\", ln):\n",
671
+ " return ln.split()[0].capitalize()\n",
672
+ "\n",
673
+ " return \"Parsec antenna\"\n",
674
+ "\n",
675
+ "def parsec_part_from_card(t: str) -> str:\n",
676
+ " m = re.search(r\"Standard\\s+SKU:\\s*([A-Z0-9]+)\", t)\n",
677
+ " return m.group(1).strip() if m else \"\"\n",
678
+ "\n",
679
+ "def parsec_desc_from_card(t: str) -> str:\n",
680
+ " m = re.search(r\"Description:\\s*(.+?)(?:\\n|$)\", t, flags=re.IGNORECASE)\n",
681
+ " return re.sub(r\"\\s+\",\" \",m.group(1).strip())[:220] if m else \"\"\n",
682
+ "\n",
683
+ "def parsec_retrieve(query: str, top_k: int = 12) -> List[Dict[str, Any]]:\n",
684
+ " qv = embedder.encode([query], normalize_embeddings=True)\n",
685
+ " qv = np.asarray(qv, dtype=np.float32)\n",
686
+ " scores, ids = parsec_index.search(qv, top_k)\n",
687
+ " out: List[Dict[str, Any]] = []\n",
688
+ " for sc, i in zip(scores[0].tolist(), ids[0].tolist()):\n",
689
+ " if 0 <= int(i) < len(parsec_cards):\n",
690
+ " card = parsec_cards[int(i)]\n",
691
+ " out.append({\n",
692
+ " \"score\": float(sc),\n",
693
+ " \"name\": parsec_name_from_card(card),\n",
694
+ " \"part_number\": parsec_part_from_card(card),\n",
695
+ " \"description\": parsec_desc_from_card(card),\n",
696
+ " \"connectors\": parsec_connectors_from_card(card),\n",
697
+ " \"mounts\": parsec_mounts_from_card(card),\n",
698
+ " \"_card\": card.lower(),\n",
699
+ " })\n",
700
+ " return out\n",
701
+ "\n",
702
+ "def choose_best_parsec(cands: List[Dict[str, Any]], mode: str) -> Dict[str, Any]:\n",
703
+ " best = None\n",
704
+ " best_score = -1e9\n",
705
+ "\n",
706
+ " for c in cands:\n",
707
+ " card = c.get(\"_card\",\"\")\n",
708
+ " mounts = c.get(\"mounts\", []) or []\n",
709
+ " score = float(c.get(\"score\", 0.0))\n",
710
+ "\n",
711
+ " if \"omni\" in card:\n",
712
+ " score += 0.6\n",
713
+ " if \"directional\" in card:\n",
714
+ " score -= 1.5\n",
715
+ "\n",
716
+ " if mode == \"vehicle\":\n",
717
+ " if any(\"magnetic\" in m for m in mounts):\n",
718
+ " score += 3.0\n",
719
+ " if any(\"through\" in m for m in mounts):\n",
720
+ " score += 2.0\n",
721
+ " if any(\"wall\" in m for m in mounts) or any(\"pole\" in m for m in mounts):\n",
722
+ " score -= 1.2\n",
723
+ " if \"app: fixed\" in card and \"mobile\" not in card:\n",
724
+ " score -= 2.0\n",
725
+ "\n",
726
+ " if mode == \"stationary\":\n",
727
+ " if any(\"wall\" in m for m in mounts):\n",
728
+ " score += 2.0\n",
729
+ " if any(\"pole\" in m for m in mounts):\n",
730
+ " score += 1.8\n",
731
+ "\n",
732
+ " if score > best_score:\n",
733
+ " best_score = score\n",
734
+ " best = c\n",
735
+ "\n",
736
+ " if not best:\n",
737
+ " return {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\",\"connectors\":\"\",\"mounts\":[]}\n",
738
+ "\n",
739
+ " best = dict(best)\n",
740
+ " best.pop(\"_card\", None)\n",
741
+ " return best\n",
742
+ "\n",
743
+ "\n",
744
+ "def infer_mimo_for_5g(model: str, canon_make: str) -> str:\n",
745
+ " \"\"\"Best-effort MIMO guess for antenna selection (2x2 vs 4x4).\"\"\"\n",
746
+ " # If model is unknown, default to 2x2 (safer ordering)\n",
747
+ " if not model or model in {\"Not applicable\", \"Not listed\"}:\n",
748
+ " return \"2x2\"\n",
749
+ "\n",
750
+ " # If the model name hints 5G, lean 4x4\n",
751
+ " if \"5g\" in model.lower() or model.upper().startswith((\"R\", \"E\", \"S\", \"IX\", \"RUTM\")):\n",
752
+ " default = \"4x4\"\n",
753
+ " else:\n",
754
+ " default = \"2x2\"\n",
755
+ "\n",
756
+ " # Use dec2025routers.csv if we can match the model under the same maker family\n",
757
+ " try:\n",
758
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
759
+ " if pool.empty:\n",
760
+ " return default\n",
761
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
762
+ " if not hit or hit[1] < MATCH_OK:\n",
763
+ " return default\n",
764
+ " row = pool.iloc[int(hit[2])]\n",
765
+ " txt2 = (str(row.get(\"Antennas (internal/external/both)\", \"\")) + \" \" + str(row.get(\"Modem Type\", \"\")) + \" \" + str(row.get(\"Special notes\",\"\"))).lower()\n",
766
+ " if \"4x4\" in txt2 or \"4 x 4\" in txt2 or \"4x 4\" in txt2:\n",
767
+ " return \"4x4\"\n",
768
+ " if \"2x2\" in txt2 or \"2 x 2\" in txt2:\n",
769
+ " return \"2x2\"\n",
770
+ " # If modem type includes 5G, lean 4x4\n",
771
+ " if \"5g\" in txt2 or \"nr\" in txt2:\n",
772
+ " return \"4x4\"\n",
773
+ " return default\n",
774
+ " except Exception:\n",
775
+ " return default\n",
776
+ "\n",
777
+ "def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:\n",
778
+ " q_stationary = f\"{router_model} {tech} {mimo} omni stationary pole wall fixed site Parsec\"\n",
779
+ " q_vehicle = f\"{router_model} {tech} {mimo} omni vehicle mobile magnetic through-bolt Parsec\"\n",
780
+ "\n",
781
+ " cand_stationary = parsec_retrieve(q_stationary, top_k=12)\n",
782
+ " cand_vehicle = parsec_retrieve(q_vehicle, top_k=12)\n",
783
+ "\n",
784
+ " s = choose_best_parsec(cand_stationary, mode=\"stationary\")\n",
785
+ " v = choose_best_parsec(cand_vehicle, mode=\"vehicle\")\n",
786
+ "\n",
787
+ " s.update({\"mimo\": mimo, \"why\": \"Stationary omni best match.\"})\n",
788
+ " v.update({\"mimo\": mimo, \"why\": \"Vehicle omni best match.\"})\n",
789
+ "\n",
790
+ " return {\"stationary_omni\": s, \"vehicle_omni\": v, \"sources\":[\"parsec_rag\"]}\n",
791
+ "\n",
792
+ "\n",
793
+ "# ============================\n",
794
+ "# Install-ready checklist\n",
795
+ "# ============================\n",
796
+ "def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
797
+ " st = ant.get(\"stationary_omni\", {})\n",
798
+ " vh = ant.get(\"vehicle_omni\", {})\n",
799
+ " if client is not None:\n",
800
+ " sys = \"Create a short, install-ready checklist for a Verizon rep. Return markdown only.\"\n",
801
+ " payload = {\"current_device\": current_sku, \"replacements\": repl, \"antennas\": {\"stationary\": st, \"vehicle\": vh}}\n",
802
+ " resp = client.responses.create(\n",
803
+ " model=OPENAI_MODEL,\n",
804
+ " reasoning=OPENAI_REASONING,\n",
805
+ " input=[{\"role\":\"system\",\"content\":sys},{\"role\":\"user\",\"content\":json.dumps(payload)}],\n",
806
+ " max_output_tokens=520,\n",
807
+ " )\n",
808
+ " return (getattr(resp, \"output_text\", \"\") or \"\").strip()\n",
809
+ " return \"\\n\".join([\n",
810
+ " \"### Install-ready checklist\",\n",
811
+ " f\"- Current device: {current_sku}\",\n",
812
+ " f\"- 5G replacement: {repl.get('repl_5g','')}\",\n",
813
+ " f\"- 4G alternative: {repl.get('repl_4g','Not applicable')}\",\n",
814
+ " f\"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})\",\n",
815
+ " f\"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})\",\n",
816
+ " \"- Next steps: confirm mounting + cable lengths + power; place order; schedule install.\",\n",
817
+ " ])\n",
818
+ "\n",
819
+ "\n",
820
+ "# ============================\n",
821
+ "# Batch mode (NO GPT)\n",
822
+ "# ============================\n",
823
+ "def parse_batch_inputs(text_blob: str, file_obj: Any) -> List[str]:\n",
824
+ " items: List[str] = []\n",
825
+ " if file_obj is not None:\n",
826
+ " try:\n",
827
+ " path = file_obj.name if hasattr(file_obj, \"name\") else str(file_obj)\n",
828
+ " df = pd.read_csv(path)\n",
829
+ " col = df.columns[0]\n",
830
+ " items.extend([str(x).strip() for x in df[col].tolist() if str(x).strip()])\n",
831
+ " except Exception:\n",
832
+ " pass\n",
833
+ " if text_blob:\n",
834
+ " for ln in str(text_blob).splitlines():\n",
835
+ " ln = ln.strip()\n",
836
+ " if ln:\n",
837
+ " items.append(ln)\n",
838
+ " seen=set()\n",
839
+ " out=[]\n",
840
+ " for x in items:\n",
841
+ " k=norm_text(x)\n",
842
+ " if k and k not in seen:\n",
843
+ " seen.add(k); out.append(x)\n",
844
+ " return out\n",
845
+ "\n",
846
+ "def run_batch(text_blob: str, file_obj: Any, include_antennas: bool):\n",
847
+ " inputs = parse_batch_inputs(text_blob, file_obj)\n",
848
+ " if not inputs:\n",
849
+ " return \"\", None, None, \"\"\n",
850
+ "\n",
851
+ " rows=[]\n",
852
+ " for item in inputs:\n",
853
+ " res = resolve_device(item)\n",
854
+ " if res.get(\"mode\") != \"ok\":\n",
855
+ " rows.append({\"Input\": item, \"Matched\":\"\", \"Status\":\"Needs review\", \"EOS\":\"\", \"EOL\":\"\", \"4G alternative\":\"\", \"5G replacement\":\"\", \"Notes\":\"Not found/ambiguous\"})\n",
856
+ " continue\n",
857
+ "\n",
858
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
859
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
860
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=False)\n",
861
+ "\n",
862
+ " rows.append({\n",
863
+ " \"Input\": item,\n",
864
+ " \"Matched\": str(life_row.get(\"sku\",\"\")),\n",
865
+ " \"Status\": status,\n",
866
+ " \"EOS\": eos,\n",
867
+ " \"EOL\": eol,\n",
868
+ " \"4G alternative\": repl.get(\"repl_4g\",\"\"),\n",
869
+ " \"5G replacement\": repl.get(\"repl_5g\",\"\"),\n",
870
+ " \"Notes\": \"\",\n",
871
+ " })\n",
872
+ "\n",
873
+ " out_df = pd.DataFrame(rows)\n",
874
+ " counts = out_df[\"Status\"].value_counts(dropna=False).to_dict()\n",
875
+ " top_5g = out_df[\"5G replacement\"].value_counts(dropna=False).head(5).to_dict()\n",
876
+ " summary = f\"Rows: {len(out_df)} | \" + \" | \".join([f\"{k}: {v}\" for k,v in counts.items()])\n",
877
+ " rollup = \"Top 5G recommendations:\\n\" + \"\\n\".join([f\"- {k}: {v}\" for k,v in top_5g.items() if str(k).strip()])\n",
878
+ "\n",
879
+ " tmp = tempfile.NamedTemporaryFile(delete=False, suffix=\".csv\")\n",
880
+ " out_df.to_csv(tmp.name, index=False)\n",
881
+ "\n",
882
+ " return summary, out_df, tmp.name, rollup\n",
883
+ "\n",
884
+ "\n",
885
+ "# ============================\n",
886
+ "# Output\n",
887
+ "# ============================\n",
888
+ "def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
889
+ " current_name = f\"{life_row.get('sku','')} — {life_row.get('description','')}\".strip(\" —\")\n",
890
+ " st = ant.get(\"stationary_omni\", {})\n",
891
+ " vh = ant.get(\"vehicle_omni\", {})\n",
892
+ "\n",
893
+ " lines = []\n",
894
+ " lines.append(f\"1. Current device: **{current_name}**\")\n",
895
+ " lines.append(f\"2. Status: **{status}**\")\n",
896
+ " lines.append(f\"3. End of Sale date: **{eos}**\")\n",
897
+ " lines.append(f\"4. End of Life date: **{eol}**\")\n",
898
+ " lines.append(f\"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**\")\n",
899
+ " lines.append(f\"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**\")\n",
900
+ " lines.append(\"7. Antenna options (Parsec-only):\")\n",
901
+ " conn_s = f\" | Conn: {st.get('connectors','')}\" if st.get(\"connectors\") else \"\"\n",
902
+ " conn_v = f\" | Conn: {vh.get('connectors','')}\" if vh.get(\"connectors\") else \"\"\n",
903
+ " lines.append(f\" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s}\")\n",
904
+ " lines.append(f\" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v}\")\n",
905
+ "\n",
906
+ " lines.append(\"\\nSources (debug):\")\n",
907
+ " for s in repl.get(\"sources\", []) if isinstance(repl.get(\"sources\"), list) else []:\n",
908
+ " lines.append(f\"- {s}\")\n",
909
+ " lines.append(\"- ParsecCatalog.pdf (local RAG)\")\n",
910
+ " lines.append(\"- routers_eos_eol_by_sku.csv (replacements)\")\n",
911
+ " return \"\\n\".join(lines)\n",
912
+ "\n",
913
+ "\n",
914
+ "# ============================\n",
915
+ "# Gradio callbacks\n",
916
+ "# IMPORTANT: no dict state and ALL events have api_name=False (prevents api_info schema generation)\n",
917
+ "# ============================\n",
918
+ "def run_lookup(user_text: str, st_json: str):\n",
919
+ " user_text = str(user_text or \"\").strip()\n",
920
+ " if not user_text:\n",
921
+ " return \"Enter a router SKU/model.\", gr.update(visible=False), gr.update(visible=False), \"{}\", \"\"\n",
922
+ "\n",
923
+ " res = resolve_device(user_text)\n",
924
+ "\n",
925
+ " if res.get(\"mode\") == \"pick\":\n",
926
+ " opts = res.get(\"options\", [])\n",
927
+ " choices = [o[\"label\"] for o in opts]\n",
928
+ " st2 = {\"mode\":\"pick\",\"options\": opts, \"raw\": user_text}\n",
929
+ " return \"Did you mean A or B? Pick one, then click Use selection.\", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), state_dump(st2), \"\"\n",
930
+ "\n",
931
+ " if res.get(\"mode\") != \"ok\":\n",
932
+ " return \"Not found.\", gr.update(visible=False), gr.update(visible=False), \"{}\", \"\"\n",
933
+ "\n",
934
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
935
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
936
+ "\n",
937
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
938
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
939
+ " mimo = infer_mimo_for_5g(repl.get(\"repl_5g\",\"\"), canon_make)\n",
940
+ " tech = \"5G\" if repl.get(\"repl_5g\") and repl.get(\"repl_5g\") != \"Not listed\" else (\"4G\" if device_is_4g(life_row) else \"Unknown\")\n",
941
+ " ant = antenna_options_for(repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech, mimo)\n",
942
+ "\n",
943
+ " output = assemble_output(life_row, status, eos, eol, repl, ant)\n",
944
+ " st_out = {\"row_idx\": int(res[\"row_idx\"]), \"repl\": repl, \"ant\": ant, \"raw\": user_text}\n",
945
+ " return output, gr.update(visible=False), gr.update(visible=False), state_dump(st_out), \"\"\n",
946
+ "\n",
947
+ "def use_selection(selected_label: str, st_json: str):\n",
948
+ " st = state_load(st_json)\n",
949
+ " if not st or st.get(\"mode\") != \"pick\":\n",
950
+ " return \"Run a search first.\", gr.update(visible=False), gr.update(visible=False), \"{}\", \"\"\n",
951
+ "\n",
952
+ " if not selected_label:\n",
953
+ " return \"Pick A or B first.\", gr.update(visible=True), gr.update(visible=True), st_json, \"\"\n",
954
+ "\n",
955
+ " chosen_row = None\n",
956
+ " for o in st.get(\"options\", []):\n",
957
+ " if o.get(\"label\") == selected_label:\n",
958
+ " chosen_row = int(o[\"row_idx\"])\n",
959
+ " break\n",
960
+ " if chosen_row is None:\n",
961
+ " return \"Pick a valid option.\", gr.update(visible=True), gr.update(visible=True), st_json, \"\"\n",
962
+ "\n",
963
+ " life_row = df_eos.iloc[int(chosen_row)]\n",
964
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
965
+ "\n",
966
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
967
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
968
+ " mimo = infer_mimo_for_5g(repl.get(\"repl_5g\",\"\"), canon_make)\n",
969
+ " tech = \"5G\" if repl.get(\"repl_5g\") and repl.get(\"repl_5g\") != \"Not listed\" else (\"4G\" if device_is_4g(life_row) else \"Unknown\")\n",
970
+ " ant = antenna_options_for(repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech, mimo)\n",
971
+ "\n",
972
+ " output = assemble_output(life_row, status, eos, eol, repl, ant)\n",
973
+ " st_out = {\"row_idx\": int(chosen_row), \"repl\": repl, \"ant\": ant, \"raw\": st.get(\"raw\",\"\")}\n",
974
+ " return output, gr.update(visible=False), gr.update(visible=False), state_dump(st_out), \"\"\n",
975
+ "\n",
976
+ "def make_install_ready(st_json: str):\n",
977
+ " st = state_load(st_json)\n",
978
+ " if not st or \"row_idx\" not in st:\n",
979
+ " return \"Run a lookup first.\"\n",
980
+ " life_row = df_eos.iloc[int(st[\"row_idx\"])]\n",
981
+ " current_sku = str(life_row.get(\"sku\",\"\") or \"\")\n",
982
+ " return install_ready_checklist(current_sku, st.get(\"repl\", {}) or {}, st.get(\"ant\", {}) or {})\n",
983
+ "\n",
984
+ "\n",
985
+ "# ============================\n",
986
+ "# UI\n",
987
+ "# ============================\n",
988
+ "with gr.Blocks(title=\"Only-Routers\") as demo:\n",
989
+ " gr.Markdown(\"## Only-Routers\\nSingle lookup + Batch upload for Verizon reps.\")\n",
990
+ "\n",
991
+ " with gr.Tabs():\n",
992
+ " with gr.Tab(\"Single\"):\n",
993
+ " user_text = gr.Textbox(label=\"Router SKU or model\", placeholder=\"Examples: IBR650B, AER1600, ES450, WR21, RUT240\", lines=1)\n",
994
+ " st = gr.State(\"{}\") # JSON string\n",
995
+ "\n",
996
+ " check_btn = gr.Button(\"Check\", variant=\"primary\")\n",
997
+ " pick_dd = gr.Dropdown(label=\"Pick A or B\", choices=[], visible=False)\n",
998
+ " use_btn = gr.Button(\"Use selection\", visible=False)\n",
999
+ "\n",
1000
+ " output_md = gr.Markdown()\n",
1001
+ "\n",
1002
+ " install_btn = gr.Button(\"Make install-ready checklist\")\n",
1003
+ " install_md = gr.Markdown()\n",
1004
+ "\n",
1005
+ " check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st, install_md], api_name=False)\n",
1006
+ " use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st, install_md], api_name=False)\n",
1007
+ " install_btn.click(fn=make_install_ready, inputs=[st], outputs=[install_md], api_name=False)\n",
1008
+ "\n",
1009
+ " with gr.Tab(\"Batch\"):\n",
1010
+ " gr.Markdown(\"Paste one per line or upload a CSV (first column). Batch runs fast (no GPT).\")\n",
1011
+ " batch_text = gr.Textbox(label=\"Paste devices (one per line)\", lines=8, placeholder=\"WR21\\nRUT240\\nIBR650B\")\n",
1012
+ " batch_file = gr.File(label=\"Upload CSV\", file_types=[\".csv\"])\n",
1013
+ " include_ant = gr.Checkbox(label=\"Include antenna picks (slower)\", value=False)\n",
1014
+ " run_btn = gr.Button(\"Run batch\", variant=\"primary\")\n",
1015
+ "\n",
1016
+ " summary_md = gr.Markdown()\n",
1017
+ " rollup_md = gr.Markdown()\n",
1018
+ " table = gr.Dataframe(interactive=False, wrap=True)\n",
1019
+ " dl = gr.File(label=\"Download results CSV\")\n",
1020
+ "\n",
1021
+ " run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md], api_name=False)\n",
1022
+ "\n",
1023
+ "# IMPORTANT: On Spaces, demo.launch() is correct; do NOT use share=True.\n",
1024
+ "demo.launch(show_api=False)\n"
1025
+ ]
1026
+ }
1027
+ ],
1028
+ "metadata": {
1029
+ "kernelspec": {
1030
+ "display_name": "Python 3",
1031
+ "name": "python3"
1032
+ },
1033
+ "language_info": {
1034
+ "name": "python"
1035
+ }
1036
+ },
1037
+ "nbformat": 4,
1038
+ "nbformat_minor": 5
1039
+ }
Updates/routers_eos_eol_by_sku_old.csv ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ sku,manufacturer,Device Type,end_of_sale,end_of_life,description,suggested_replacement,advanced_5g_option,region,source,source_detail,notes
2
+ 3GHWIC,Cisco,Modem,2017,2024,3GHWIC &4GLTE2.0EHWIC,LTE EHWIC-VA (4G LTE 2.5),5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
3
+ 4G LTE EHWIC MODULES (ISR,Cisco,Modem,2019,2024,4G LTE EHWIC Modules (ISR,LTE NIMs (ISR 4000) or,5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
4
+ 4GLTE2.0EHWIC,Cisco,Modem,2017,2024,3GHWIC &4GLTE2.0EHWIC,LTE EHWIC-VA (4G LTE 2.5),5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
5
+ 6300-CX,Digi,Router,2022,2024,Accelerated 6300-CX (LTE extender),EX15 or IX20 (LTE router),EX50,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
6
+ 6310-DX,Digi,Router,2022,2024,Accelerated 6310-DX (LTE router),IX20 (Cat 4/6 router),EX50,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
7
+ 6330-MX,Digi,Router,2022,2027,Digi 6330-MX (LTE router),5G) or IX20 (LTE) — (varies),EX50,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,EX50
8
+ 881G,Cisco,Router,7/29/19,7/31/24,"881G, 887, 896, 897, 4G ISR G2 Routers",Cisco ISR 1100 Series and (C111x-8P LTE C1120 series) Wi-Fi/LTE - SD-WAN routers,1800 Series with 5G PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
9
+ RUT240,Teltonika,Router,6/24/24,2029,Teltonika RUT240 Cat 4 Router,Teltonika RUT241 Cat 4 Router,RUTM50,North America,EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf,
10
+ RUT360,Teltonika,Router,6/24/24,2029,Teltonika RUT360 Cat 6 Router,Teltonika RUT361 Cat 4 Router,RUTM50,North America,EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf,
11
+ 887,Cisco,Router,7/29/19,7/31/24,"881G, 887, 896, 897, 4G ISR G2 Routers",Cisco ISR 1100 Series and (C111x-8P LTE C1120 series) Wi-Fi/LTE - SD-WAN routers,5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
12
+ 896,Cisco,Router,7/29/19,7/31/24,"881G, 887, 896, 897, 4G ISR G2 Routers",Cisco ISR 1100 Series and (C111x-8P LTE C1120 series) Wi-Fi/LTE - SD-WAN routers,5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
13
+ 897,Cisco,Router,7/29/19,7/31/24,"881G, 887, 896, 897, 4G ISR G2 Routers",Cisco ISR 1100 Series and (C111x-8P LTE C1120 series) Wi-Fi/LTE - SD-WAN routers,5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
14
+ ACM5004-LR-E,Opengear,Router,12/1/16,1/1/21,ACM5000 family (ACM5004-LR-E etc),ACM7000 Family,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
15
+ ACM5504-5-LR-I,Opengear,Router,12/1/16,1/1/21,ACM5500 family (ACM5504-5-LR-I etc),ACM7000 Family,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
16
+ ACM7004-2-LA,Opengear,Router,3/31/17,6/30/21,ACM7004-2-Lx family (ACM7004-2-LA/LR/LV etc),ACM7004-2-L,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
17
+ ACM7004-2-LR,Opengear,Router,3/31/17,6/30/21,ACM7004-2-Lx family (ACM7004-2-LA/LR/LV etc),ACM7004-2-L,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
18
+ ACM7004-2-LV,Opengear,Router,3/31/17,6/30/21,ACM7004-2-Lx family (ACM7004-2-LA/LR/LV etc),ACM7004-2-L,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
19
+ AER1600,Cradlepoint,Router,2021,2026,AER1600 Series (branch router),E300 Series,E400,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
20
+ AER2100,Cradlepoint,Router,2018-05,2023-05,AER2100 Series (branch router) | Series AER2100 4G Router,E300/E3000 Series | Series E3000 (Cat18) (NetCloud Enterprise),E400,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
21
+ AER2200,Cradlepoint,Router,2022,2027,AER2200 Series (branch router),E3000 Series,E400,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
22
+ AER3100,Cradlepoint,Router,2018-05,2023-05,AER3100 Series (branch router) | Series AER3100 Router,E3000 Series | E3000 Series (Cat18),E400,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
23
+ AER3150,Cradlepoint,Router,2018,2023,AER3150 (dual-modem router),E3000 Series,E400,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
24
+ AIRLINK MOBILE,Sierra Wireless (Semtech),Router,2019,2023,AirLink mobile),RV55 (LTE-A Pro),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
25
+ CBA750B,Cradlepoint,Router,2016,2021,CBA750B (ARC adapter),L950 or W1850 Adapter,W1850,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
26
+ CBA850,Cradlepoint,Router,5/11/22,2027-05,CBA850 LTE Adapter (Ethernet-to-cell) | CBA850(ARC adapter),Adapter L950 LTE (Cat7) W1850 5G; or Wideband | L950 or W1850 Adapter,W1850,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
27
+ CISCO 4G LTE MODULES EHWIC G2),Cisco,Router,2/22/19,2/29/24,Cisco 4G LTE Modules EHWIC G2),Cisco Network LTE Interface Modules for (NIM) ISR 4000/ - Catalyst 8300,5G PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
28
+ CM7100,Opengear,Router,12/31/24,12/31/30,CM7100 family (all SKUs),CM8100 Family,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
29
+ IBR600B,Cradlepoint,Router,2020,2025,IBR600B Series (CORIBR600B),S400 or S700 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
30
+ EX450,Sierra Wireless (Semtech),Router,2021,2025,AirLink ES450 (LTE enterprise),LX60 (LTE),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
31
+ ES450,Sierra Wireless (Semtech),Router,2021,2025,AirLink ES450 (LTE enterprise),LX60 (LTE),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
32
+ EX15 (cat 6 and cat 11 versions only),Digi,Router,2023,2024,Digi EX15 Cellular Extender (Cat6),Digi Cellular EX15 Extender (Cat7) model current -,EX50,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,Early EOS | support through
33
+ GX400,Sierra Wireless (Semtech),Router,2016,2021,AirLink GX400/GX440 (3G/4G),RV50 (LTE),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
34
+ GX440,Sierra Wireless (Semtech),Router,2015,2021,AirLink GX400/GX440 (3G/4G) | AirLink GX440 (LTE gateway),RV50 (LTE) | GX450 (LTE),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
35
+ GX450,Sierra Wireless (Semtech),Router,12/31/19,6/30/23,AirLink GX450 Wireless Gateway (LTE Cat3),AirLink RV55 LTE-A Pro Gateway (Cat12) (direct successor),XR60,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
36
+ 819,Cisco,Router,2021,2025,Hardened (HSPA+/EVDO),IR1101 or IR1800 series,5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
37
+ IBR1100,Cradlepoint,Router,2018-05,2023-11,IBR1100 Series (rugged router) | IBR1100/1150 (Rugged Series router),IBR920 Series | Rugged) R920 (Cat20 - for vehicular/FirstNet,R980,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
38
+ IBR1150,Cradlepoint,Router,2018-05,2023-11,"IBR1100/1150 (Rugged Series router) | IBR1150 (rugged, no WiFi)",Rugged) R920 (Cat20 - for vehicular/FirstNet | IBR1700 or R920,R1900,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
39
+ IBR200,Cradlepoint,Router,2024,2029,IBR200 Series (COR IBR600C),S400 or S700 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
40
+ IBR350,Cradlepoint,Router,2018,2023,IBR350 (M2M router),S400 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
41
+ IBR600,Cradlepoint,Router,2018-05,2023-05,IBR600 Series (COR IBR600) | IBR600/650 Series (COR router),S400 or S700 Series | IBR600C (Cat4) or IBR900 (Cat11) 3°,R980,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
42
+ IBR600B,Cradlepoint,Router,2019-10,2025-03,IBR600B/650B Series (LP6 LTE),"S700/S750 Router (5G/ NetCloud LTE, IoT)",R980,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
43
+ IBR600C,Cradlepoint,Router,2024,2029,IBR200 Series (COR IBR600C) | IBR600C Series (COR IBR600C),S400 or S700 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
44
+ IBR650,Cradlepoint,Router,2018-05,2023-05,IBR600/650 Series (COR router) | IBR650 Series (no-WiFi variant),IBR600C (Cat4) or IBR900 (Cat11) 3° | S400 or S700 Series,R980,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
45
+ IBR650B,Cradlepoint,Router,2019-10,2025-03,IBR600B/650B Series (LP6 LTE) | IBR650B Series (no-WiFi variant),"S700/S750 Router (5G/ NetCloud LTE, IoT) | S400 or S700 Series",R980,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
46
+ IBR650C,Cradlepoint,Router,2024,2029,IBR650C Series (no-WiFi variant),S400 or S700 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
47
+ IBR900,Cradlepoint,Router,8/14/24,8/14/29,Rugged IBR900 Router (Cat11 LTE),"Rugged R920 Router 5G-Ready) (Cat20,",R980,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
48
+ IBR950,Cradlepoint,Router,2018,2023,IBR950 (compact router),IBR900 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
49
+ IM4208-2-,Opengear,Router,12/1/16,8/31/20,"IM4200 family (IM4208-2-..., IM4248-2-...)",IM7200,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
50
+ IM4248-2-,Opengear,Router,12/1/16,8/31/20,"IM4200 family (IM4208-2-..., IM4248-2-...)",IM7200,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
51
+ IM7200,Opengear,Router,12/31/24,12/31/30,IM7200 family (all SKUs),CM8100-10G Family,5G SA PIM,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
52
+ INDUSTRIAL ROUTER (LTE),Cisco,Router,2023,2028,Industrial Router (LTE),Catalyst IR1831 (5G),5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
53
+ INTEGRATED ROUTER (4G),Cisco,Router,2019,2024,Integrated Router (4G),IR1101 or IR1800 series,5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
54
+ IR829GW,Cisco,Router,2021,2024,IR829GW LTE (with WiFi),IR1101 or IR1800 series,5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
55
+ ISR INDUSTRIAL LTE ROUTER,Cisco,Router,3/1/23,2/29/28,ISR Industrial LTE Router,"Catalyst Rugged IR1831 Router (Cat18 LTE, 5G- ready) (Cisco IR1800 series)",5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
56
+ ISR INDUSTRIAL ROUTER (LTE),Cisco,Router,7/12/21,7/31/26,ISR Industrial Router (LTE),"Cisco Integrated IR1101 (modular Router LTE, upgradable)",5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
57
+ IX20-PR,Digi,Router,2023,2025,Digi IX20-PR (FirstNet Cat4 router),Digi IX30 (Cat7) TX54 or - FirstNet) for PR (Cat12 FirstNet LTE,IX40,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,FirstNet “PR” SKU EOS | support until ~
58
+ IX40,Digi,Router,2023,6/30/25,Digi Ix40 Cat12 Router,Digi IX40 5G (Cat19/5G model),IX40,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,Cat12 model EOS | support until
59
+ MC400LP6,Cradlepoint,Modem,2019-10,2025-03,IBR600B/650B Series (LP6 LTE),"S700/S750 Router (5G/ NetCloud LTE, IoT)",MC400 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
60
+ LS300,Sierra Wireless (Semtech),Router,2019,2019,AirLink LS300 (3G gateway),RV50,XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
61
+ LX60,Sierra Wireless (Semtech),Router,12/31/21,6/30/25,AirLink LX60 (LTE Wireless Router var.) -,AirLink LX60 SKU (consolidated Cat4) (replaces older & Verizon SKUs),XR60,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
62
+ M2M,Cisco ,Router,12/2/16,12/31/21,ISR 4G LTE M2M Router (Hardened) | IBR350 (M2M router),Industrial 1R809 Router (4G LTE) /IR829 Wi-Fi variant) (802.11 | S400 Series,R980,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
63
+ MBR1200B,Cradlepoint,Router,2018-05,2023-05,MBR1200B (mobile router) | MBR1200B P (branch router),E300 Series | by E300 d superseded by (Cat18),E400,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
64
+ MBR1400,Cradlepoint,Router,2019,2021,MBR1400 (mobile router),E300 Series,E400,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
65
+ MC400-LP5,Cradlepoint,Modem,5/11/22,2027-05,MC400-LP5 / LP6 Modular LTE Modems,MC400-1200M ( (LTE-A modular Pro Cat12) modem,MC400 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
66
+ MC400-LP6,Cradlepoint,Modem,5/11/22,2027-05,MC400-LP5 / LP6 Modular LTE Modems,MC400-1200M ( (LTE-A modular Pro Cat12) modem,MC400 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
67
+ MG21,Cisco Meraki,Adapter,2024,2029,MG21 Cellular Gateway (Cat 6 LTE) | MG21/MG21E (LTE Not - FOL,MG41 (Cat 18 LTE) | MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
68
+ MG21-HW-NA,Cisco Meraki,Adapter,5/31/25,5/31/29,MG21-HW-NA,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,North America,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-18
69
+ MG21-HW-WW,Cisco Meraki,Adapter,5/31/25,5/31/29,MG21-HW-WW,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,Worldwide,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-18
70
+ MG21E,Cisco Meraki,Adapter,,,MG21/MG21E (LTE Not - FOL,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
71
+ MG21E-HW-NA,Cisco Meraki,Adapter,9/18/24,9/18/29,MG21E-HW-NA,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,North America,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-18
72
+ MG21E-HW-WW,Cisco Meraki,Adapter,7/10/24,9/18/29,MG21E-HW-WW,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,Worldwide,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-18
73
+ MG51,Cisco Meraki,Adapter,11/28/25,5/30/30,Cellular MG51 Gateway (LTE Cat 20) | MG51 Cellular Gateway (5G),"Cellular MG51E Gateway (external antenna Cat 20) version, - model current | MG52 (5G Standalone)",MG52,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
74
+ MG51-HW,Cisco Meraki,Adapter,11/28/25,5/30/30,MG51-HW,"Cellular MG51E Gateway (external antenna Cat 20) version, - model current | MG52 (5G Standalone)",MG52,Global,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2025-05-30
75
+ MG51E-HW,Cisco Meraki,Adapter,11/28/25,5/30/30,MG51E-HW,"Cellular MG51E Gateway (external antenna Cat 20) version, - model current | MG52 (5G Standalone)",MG52,Global,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2025-05-30
76
+ MG90,Sierra Wireless (Semtech),Router,2020-07,2023,AirLink MG90 Wireless Router (Cat6),AirLink XR80 (Cat20 LTE & 5G) XR90 (5G) or Mobile Routers,XR80/90,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,approx Pert std su y post-ship
77
+ MP70,Sierra Wireless (Semtech),Router,12/31/21,6/30/25,AirLink MP70 (3G/4G) | AirLink MP70 Wireless (Cat6 LTE-A Router),RV50 (LTE) | AirLink MP70 Cat?2 (FirstNet LTE-A Pro Ready) upgraded - model,XR80/90,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
78
+ MTE-LAT6-B07-US,MultiTech,Router,,9/10/25,MTE-LAT6-B07-US,rCell 300 Series Intelligent Router,,US,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
79
+ MTR-G3-B16,MultiTech,Router,,9/8/21,MTR-G3-B16,MTR-MNG2-B07,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,NRND 2021-04-28; NEOL 2021-09-08
80
+ MTR-G3-B16-EU-GB,MultiTech,Router,,9/8/21,MTR-G3-B16-EU-GB,MTR-MNG2-B07-WW,,EU/UK,Web (MultiTech EOL products list),https://multitech.com/eol-products/,NRND 2021-04-28; NEOL 2021-09-08
81
+ MTR-MNG2-B07,MultiTech,Router,,9/24/25,MTR-MNG2-B07,rCell 300 Series Intelligent Router,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
82
+ MTR-MNG2-B07-WW,MultiTech,Router,,9/24/25,MTR-MNG2-B07-WW,rCell 300 Series Intelligent Router,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
83
+ MTR-MNG2-B10,MultiTech,Router,,9/24/25,MTR-MNG2-B10,rCell 300 Series Intelligent Router,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
84
+ MTR-MNG2-B10-WW,MultiTech,Router,,9/24/25,MTR-MNG2-B10-WW,rCell 300 Series Intelligent Router,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
85
+ MX64,Cisco Meraki,Router,2022,2027,MX64 Security Appliance,,MX67 + MG52,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
86
+ RV50,Sierra Wireless (Semtech),Router,2019,2022,AirLink RV50 (LTE gateway),RV50X (LTE Advanced),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
87
+ RV50X,Sierra Wireless (Semtech),Router,12/31/25,6/30/26,AirLink RV50X (SKU 1103052) - EOL due to component availability | AirLink Raven Wireless RV50X (LTE Cat6) GSA,"RX55, RV55, XR60 (per distributor) | AirLink RX55 LTE-A Router (Cat7/Cat13, new rugged series)",XR60,NA/EMEA | North America,Web (Distributor listing) | EOS/EOL North America PDF,https://www.westbase.io/shop/product/sierra-wireless-rv50x-sierra-wireless-rv50x-naemea | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,Dates are Last Time Buy / Last Time Ship from listing | GSA SKU EOS | (Std RV50/ - still RV50xX supported
88
+ TX54,Digi,Router,2023,2028,Digi TX54 (Cat11 LTE-A Router),Digi TX54 (Cat12 Responder” “Primary SKU) TX54 5G or - updated models,TX54 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,global SKUs EOS | std. 5-yr support
89
+ TX64,Digi,Router,2023,2028,Digi TX64 (Cat11/ Cat18 Router),"Digi TX64 5G (Cat20, 5G Rail latest variants) - model",TX64 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,Cat11 & Cat18 SKUs EOS | support until
90
+ WR11,Digi,Router,2018,2023,TransPort WR11 (LTE router),IX10 (Industrial LTE),IX40,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
91
+ WR11-XT,Digi,Router,9/30/21,2022-03,Digi WR11 XT (LTE Router),Digi IX10 IX20 (LTE or Cat-M1/Cat4 routers),IX40,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
92
+ WR21,Digi,Router,9/30/21,2022-03,TransPort WR21 (3G/4G Router) | TransPort WR21 (4G router),Digi Industrial IX20 (DAL Router OS) 5° | IX20 (Industrial LTE),IX40,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
93
+ WR44,Digi,Router,2016,2021,TransPort WR44 (4G mobile router),Series (IX15/IX20 or TX54),IX40,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,IX/TX
94
+ WR44-R,Digi,Router,2019,2023,TransPort WR44- R/WR44 RR (Rugged),"Mobile TX54/TX64 Routers (Cat11/Cat18, options) ","Mobile TX54/TX64 Routers (Cat11/Cat18, options) 5G",North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,varies ~ by SKU; NRND in | est.5- support yr window
95
+ WR44-RR,Digi,Router,2019,2023,TransPort WR44- R/WR44 RR (Rugged),"Mobile TX54/TX64 Routers (Cat11/Cat18, options) ","Mobile TX54/TX64 Routers (Cat11/Cat18, options) 5G",North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,varies ~ by SKU; NRND in | est.5- support yr window
96
+ Z3-HW,Cisco Meraki,Router,9/4/24,9/4/29,Z3-HW,Meraki Z4C,Z4C+MG52,Global,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-04
97
+ Z3C,Cisco Meraki,Router,9/4/24,9/4/29,Teleworker Z3C Gateway (LTE) - SKU | Z3C Teleworker Gateway (LTE),Meraki Z4C,Z4C+MG52,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
98
+ Z3C-HW-NA,Cisco Meraki,Router,9/4/24,9/4/29,Z3C-HW-NA,Meraki Z4C,Z4C+MG52,North America,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-04
app.py CHANGED
@@ -117,6 +117,77 @@ if not os.path.exists(PARSEC_PDF):
117
  df_eos = pd.read_csv(EOS_PATH).copy()
118
  df_dec = pd.read_csv(DEC_PATH).copy()
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  def region_ok(x: Any) -> bool:
121
  s = str(x or "").strip().lower()
122
  if not s:
@@ -179,6 +250,15 @@ def parse_date_field(x: Any) -> ParsedDate:
179
  if not raw:
180
  return ParsedDate(raw="", kind="missing", value=None)
181
 
 
 
 
 
 
 
 
 
 
182
  if re.fullmatch(r"\d{4}", raw):
183
  y = int(raw)
184
  if y == TODAY.year:
@@ -187,6 +267,7 @@ def parse_date_field(x: Any) -> ParsedDate:
187
  return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
188
  return ParsedDate(raw=raw, kind="year", value=date(y, 12, 31))
189
 
 
190
  if re.fullmatch(r"\d{4}-\d{2}", raw):
191
  try:
192
  y, m = raw.split("-")
@@ -194,6 +275,17 @@ def parse_date_field(x: Any) -> ParsedDate:
194
  except Exception:
195
  return ParsedDate(raw=raw, kind="bad", value=None)
196
 
 
 
 
 
 
 
 
 
 
 
 
197
  if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
198
  try:
199
  dt = datetime.strptime(raw, "%Y-%m-%d").date()
@@ -346,7 +438,34 @@ def extract_model_token(text: str) -> str:
346
 
347
  def device_is_4g(row: pd.Series) -> bool:
348
  # Detect LTE/4G even when the description uses "Cat 4 / Cat6 / Cat 12" without saying "LTE"
349
- t = norm_text(row.get("description","")) + " " + norm_text(row.get("notes",""))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
 
351
  # If it explicitly says 5G/NR, treat as not 4G-only
352
  if ("5g" in t) or ("nr" in t):
 
117
  df_eos = pd.read_csv(EOS_PATH).copy()
118
  df_dec = pd.read_csv(DEC_PATH).copy()
119
 
120
+
121
+ def _canonize_eos_columns(df: pd.DataFrame) -> pd.DataFrame:
122
+ """Normalize lifecycle CSV column names (case-insensitive) and create expected columns."""
123
+ # Map various header spellings to canonical names used by the app
124
+ mapping = {}
125
+ for c in df.columns:
126
+ k = str(c).strip().lower().replace(" ", "_")
127
+ if k in {"sku", "model", "device", "device_sku"}:
128
+ mapping[c] = "sku"
129
+ elif k in {"manufacturer", "make", "vendor"}:
130
+ mapping[c] = "manufacturer"
131
+ elif k in {"device_type", "type"}:
132
+ mapping[c] = "device_type"
133
+ elif k in {"end_of_sale", "eos", "end_sale", "end_of_sales"}:
134
+ mapping[c] = "end_of_sale"
135
+ elif k in {"end_of_life", "eol", "end_life"}:
136
+ mapping[c] = "end_of_life"
137
+ elif k in {"suggested_replacement", "replacement_4g", "lte_replacement", "replacement_lte", "replacement"}:
138
+ mapping[c] = "suggested_replacement"
139
+ elif k in {"advanced_5g_option", "replacement_5g", "fiveg_replacement", "5g_replacement", "upgrade_5g"}:
140
+ mapping[c] = "advanced_5g_option"
141
+ elif k in {"region", "market"}:
142
+ mapping[c] = "region"
143
+ elif k in {"notes", "note"}:
144
+ mapping[c] = "notes"
145
+ elif k in {"description", "device_description", "name"}:
146
+ mapping[c] = "description"
147
+
148
+ df = df.rename(columns=mapping).copy()
149
+
150
+ # Create expected columns if missing
151
+ if "sku" not in df.columns:
152
+ # Try the common capitalized header as a fallback
153
+ if "SKU" in df.columns:
154
+ df["sku"] = df["SKU"].astype(str)
155
+ else:
156
+ df["sku"] = ""
157
+
158
+ if "manufacturer" not in df.columns:
159
+ df["manufacturer"] = ""
160
+
161
+ if "device_type" not in df.columns:
162
+ df["device_type"] = ""
163
+
164
+ if "description" not in df.columns:
165
+ # If the simplified file removed description, use SKU as description (still searchable)
166
+ df["description"] = df["sku"].astype(str)
167
+
168
+ if "notes" not in df.columns:
169
+ df["notes"] = ""
170
+
171
+ if "region" not in df.columns:
172
+ df["region"] = ""
173
+
174
+ if "suggested_replacement" not in df.columns:
175
+ df["suggested_replacement"] = ""
176
+
177
+ if "advanced_5g_option" not in df.columns:
178
+ df["advanced_5g_option"] = ""
179
+
180
+ if "end_of_sale" not in df.columns:
181
+ df["end_of_sale"] = ""
182
+
183
+ if "end_of_life" not in df.columns:
184
+ df["end_of_life"] = ""
185
+
186
+ return df
187
+
188
+ df_eos = _canonize_eos_columns(df_eos)
189
+
190
+
191
  def region_ok(x: Any) -> bool:
192
  s = str(x or "").strip().lower()
193
  if not s:
 
250
  if not raw:
251
  return ParsedDate(raw="", kind="missing", value=None)
252
 
253
+ # Common US formats: M/D/YY or M/D/YYYY (e.g., 6/24/24, 9/30/21)
254
+ for fmt in ("%m/%d/%y", "%m/%d/%Y", "%-m/%-d/%y", "%-m/%-d/%Y"):
255
+ try:
256
+ dt = datetime.strptime(raw, fmt).date()
257
+ return ParsedDate(raw=raw, kind="full", value=dt)
258
+ except Exception:
259
+ pass
260
+
261
+ # ISO-ish: YYYY
262
  if re.fullmatch(r"\d{4}", raw):
263
  y = int(raw)
264
  if y == TODAY.year:
 
267
  return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
268
  return ParsedDate(raw=raw, kind="year", value=date(y, 12, 31))
269
 
270
+ # YYYY-MM
271
  if re.fullmatch(r"\d{4}-\d{2}", raw):
272
  try:
273
  y, m = raw.split("-")
 
275
  except Exception:
276
  return ParsedDate(raw=raw, kind="bad", value=None)
277
 
278
+ # YYYY-MM-DD
279
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
280
+ try:
281
+ dt = datetime.strptime(raw, "%Y-%m-%d").date()
282
+ return ParsedDate(raw=raw, kind="full", value=dt)
283
+ except Exception:
284
+ return ParsedDate(raw=raw, kind="bad", value=None)
285
+
286
+ # Last resort: leave as raw (unparsed)
287
+ return ParsedDate(raw=raw, kind="bad", value=None)
288
+
289
  if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
290
  try:
291
  dt = datetime.strptime(raw, "%Y-%m-%d").date()
 
438
 
439
  def device_is_4g(row: pd.Series) -> bool:
440
  # Detect LTE/4G even when the description uses "Cat 4 / Cat6 / Cat 12" without saying "LTE"
441
+ t = norm_text(row.get("description","")) + " " + norm_text(row.get("notes","")) + " " + norm_text(row.get("sku",""))
442
+
443
+ # If it explicitly says 5G/NR, treat as not 4G-only
444
+ if ("5g" in t) or ("nr" in t):
445
+ return False
446
+
447
+ # Classic signals
448
+ if ("lte" in t) or ("4g" in t):
449
+ return True
450
+
451
+ # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)
452
+ if re.search(r"\bcat\s*[-]?\s*(m1|m2)\b", t):
453
+ return True
454
+
455
+ m = re.search(r"\bcat\s*[-]?\s*(\d{1,2})\b", t)
456
+ if m:
457
+ try:
458
+ cat = int(m.group(1))
459
+ if 0 < cat <= 20:
460
+ return True
461
+ except Exception:
462
+ pass
463
+
464
+ # If "cat" appears at all, it's almost always LTE-family
465
+ if "cat" in t:
466
+ return True
467
+
468
+ return False
469
 
470
  # If it explicitly says 5G/NR, treat as not 4G-only
471
  if ("5g" in t) or ("nr" in t):
routers_eos_eol_by_sku.csv CHANGED
@@ -1,98 +1,426 @@
1
- sku,manufacturer,Device Type,end_of_sale,end_of_life,description,suggested_replacement,advanced_5g_option,region,source,source_detail,notes
2
- 3GHWIC,Cisco,Modem,2017,2024,3GHWIC &4GLTE2.0EHWIC,LTE EHWIC-VA (4G LTE 2.5),5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
3
- 4G LTE EHWIC MODULES (ISR,Cisco,Modem,2019,2024,4G LTE EHWIC Modules (ISR,LTE NIMs (ISR 4000) or,5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
4
- 4GLTE2.0EHWIC,Cisco,Modem,2017,2024,3GHWIC &4GLTE2.0EHWIC,LTE EHWIC-VA (4G LTE 2.5),5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
5
- 6300-CX,Digi,Router,2022,2024,Accelerated 6300-CX (LTE extender),EX15 or IX20 (LTE router),EX50,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
6
- 6310-DX,Digi,Router,2022,2024,Accelerated 6310-DX (LTE router),IX20 (Cat 4/6 router),EX50,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
7
- 6330-MX,Digi,Router,2022,2027,Digi 6330-MX (LTE router),5G) or IX20 (LTE) — (varies),EX50,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,EX50
8
- 881G,Cisco,Router,7/29/19,7/31/24,"881G, 887, 896, 897, 4G ISR G2 Routers",Cisco ISR 1100 Series and (C111x-8P LTE C1120 series) Wi-Fi/LTE - SD-WAN routers,1800 Series with 5G PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
9
- RUT240,Teltonika,Router,6/24/24,2029,Teltonika RUT240 Cat 4 Router,Teltonika RUT241 Cat 4 Router,RUTM50,North America,EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf,
10
- RUT360,Teltonika,Router,6/24/24,2029,Teltonika RUT360 Cat 6 Router,Teltonika RUT361 Cat 4 Router,RUTM50,North America,EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf,
11
- 887,Cisco,Router,7/29/19,7/31/24,"881G, 887, 896, 897, 4G ISR G2 Routers",Cisco ISR 1100 Series and (C111x-8P LTE C1120 series) Wi-Fi/LTE - SD-WAN routers,5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
12
- 896,Cisco,Router,7/29/19,7/31/24,"881G, 887, 896, 897, 4G ISR G2 Routers",Cisco ISR 1100 Series and (C111x-8P LTE C1120 series) Wi-Fi/LTE - SD-WAN routers,5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
13
- 897,Cisco,Router,7/29/19,7/31/24,"881G, 887, 896, 897, 4G ISR G2 Routers",Cisco ISR 1100 Series and (C111x-8P LTE C1120 series) Wi-Fi/LTE - SD-WAN routers,5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
14
- ACM5004-LR-E,Opengear,Router,12/1/16,1/1/21,ACM5000 family (ACM5004-LR-E etc),ACM7000 Family,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
15
- ACM5504-5-LR-I,Opengear,Router,12/1/16,1/1/21,ACM5500 family (ACM5504-5-LR-I etc),ACM7000 Family,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
16
- ACM7004-2-LA,Opengear,Router,3/31/17,6/30/21,ACM7004-2-Lx family (ACM7004-2-LA/LR/LV etc),ACM7004-2-L,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
17
- ACM7004-2-LR,Opengear,Router,3/31/17,6/30/21,ACM7004-2-Lx family (ACM7004-2-LA/LR/LV etc),ACM7004-2-L,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
18
- ACM7004-2-LV,Opengear,Router,3/31/17,6/30/21,ACM7004-2-Lx family (ACM7004-2-LA/LR/LV etc),ACM7004-2-L,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
19
- AER1600,Cradlepoint,Router,2021,2026,AER1600 Series (branch router),E300 Series,E400,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
20
- AER2100,Cradlepoint,Router,2018-05,2023-05,AER2100 Series (branch router) | Series AER2100 4G Router,E300/E3000 Series | Series E3000 (Cat18) (NetCloud Enterprise),E400,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
21
- AER2200,Cradlepoint,Router,2022,2027,AER2200 Series (branch router),E3000 Series,E400,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
22
- AER3100,Cradlepoint,Router,2018-05,2023-05,AER3100 Series (branch router) | Series AER3100 Router,E3000 Series | E3000 Series (Cat18),E400,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
23
- AER3150,Cradlepoint,Router,2018,2023,AER3150 (dual-modem router),E3000 Series,E400,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
24
- AIRLINK MOBILE,Sierra Wireless (Semtech),Router,2019,2023,AirLink mobile),RV55 (LTE-A Pro),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
25
- CBA750B,Cradlepoint,Router,2016,2021,CBA750B (ARC adapter),L950 or W1850 Adapter,W1850,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
26
- CBA850,Cradlepoint,Router,5/11/22,2027-05,CBA850 LTE Adapter (Ethernet-to-cell) | CBA850(ARC adapter),Adapter L950 LTE (Cat7) W1850 5G; or Wideband | L950 or W1850 Adapter,W1850,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
27
- CISCO 4G LTE MODULES EHWIC G2),Cisco,Router,2/22/19,2/29/24,Cisco 4G LTE Modules EHWIC G2),Cisco Network LTE Interface Modules for (NIM) ISR 4000/ - Catalyst 8300,5G PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
28
- CM7100,Opengear,Router,12/31/24,12/31/30,CM7100 family (all SKUs),CM8100 Family,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
29
- IBR600B,Cradlepoint,Router,2020,2025,IBR600B Series (CORIBR600B),S400 or S700 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
30
- EX450,Sierra Wireless (Semtech),Router,2021,2025,AirLink ES450 (LTE enterprise),LX60 (LTE),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
31
- ES450,Sierra Wireless (Semtech),Router,2021,2025,AirLink ES450 (LTE enterprise),LX60 (LTE),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
32
- EX15 (cat 6 and cat 11 versions only),Digi,Router,2023,2024,Digi EX15 Cellular Extender (Cat6),Digi Cellular EX15 Extender (Cat7) model current -,EX50,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,Early EOS | support through
33
- GX400,Sierra Wireless (Semtech),Router,2016,2021,AirLink GX400/GX440 (3G/4G),RV50 (LTE),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
34
- GX440,Sierra Wireless (Semtech),Router,2015,2021,AirLink GX400/GX440 (3G/4G) | AirLink GX440 (LTE gateway),RV50 (LTE) | GX450 (LTE),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
35
- GX450,Sierra Wireless (Semtech),Router,12/31/19,6/30/23,AirLink GX450 Wireless Gateway (LTE Cat3),AirLink RV55 LTE-A Pro Gateway (Cat12) (direct successor),XR60,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
36
- 819,Cisco,Router,2021,2025,Hardened (HSPA+/EVDO),IR1101 or IR1800 series,5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
37
- IBR1100,Cradlepoint,Router,2018-05,2023-11,IBR1100 Series (rugged router) | IBR1100/1150 (Rugged Series router),IBR920 Series | Rugged) R920 (Cat20 - for vehicular/FirstNet,R980,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
38
- IBR1150,Cradlepoint,Router,2018-05,2023-11,"IBR1100/1150 (Rugged Series router) | IBR1150 (rugged, no WiFi)",Rugged) R920 (Cat20 - for vehicular/FirstNet | IBR1700 or R920,R1900,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
39
- IBR200,Cradlepoint,Router,2024,2029,IBR200 Series (COR IBR600C),S400 or S700 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
40
- IBR350,Cradlepoint,Router,2018,2023,IBR350 (M2M router),S400 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
41
- IBR600,Cradlepoint,Router,2018-05,2023-05,IBR600 Series (COR IBR600) | IBR600/650 Series (COR router),S400 or S700 Series | IBR600C (Cat4) or IBR900 (Cat11) 3°,R980,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
42
- IBR600B,Cradlepoint,Router,2019-10,2025-03,IBR600B/650B Series (LP6 LTE),"S700/S750 Router (5G/ NetCloud LTE, IoT)",R980,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
43
- IBR600C,Cradlepoint,Router,2024,2029,IBR200 Series (COR IBR600C) | IBR600C Series (COR IBR600C),S400 or S700 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
44
- IBR650,Cradlepoint,Router,2018-05,2023-05,IBR600/650 Series (COR router) | IBR650 Series (no-WiFi variant),IBR600C (Cat4) or IBR900 (Cat11) 3° | S400 or S700 Series,R980,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
45
- IBR650B,Cradlepoint,Router,2019-10,2025-03,IBR600B/650B Series (LP6 LTE) | IBR650B Series (no-WiFi variant),"S700/S750 Router (5G/ NetCloud LTE, IoT) | S400 or S700 Series",R980,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
46
- IBR650C,Cradlepoint,Router,2024,2029,IBR650C Series (no-WiFi variant),S400 or S700 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
47
- IBR900,Cradlepoint,Router,8/14/24,8/14/29,Rugged IBR900 Router (Cat11 LTE),"Rugged R920 Router 5G-Ready) (Cat20,",R980,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
48
- IBR950,Cradlepoint,Router,2018,2023,IBR950 (compact router),IBR900 Series,R980,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
49
- IM4208-2-,Opengear,Router,12/1/16,8/31/20,"IM4200 family (IM4208-2-..., IM4248-2-...)",IM7200,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
50
- IM4248-2-,Opengear,Router,12/1/16,8/31/20,"IM4200 family (IM4208-2-..., IM4248-2-...)",IM7200,,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
51
- IM7200,Opengear,Router,12/31/24,12/31/30,IM7200 family (all SKUs),CM8100-10G Family,5G SA PIM,Global,Web (Opengear lifecycle list),https://opengear.com/end-life-products,
52
- INDUSTRIAL ROUTER (LTE),Cisco,Router,2023,2028,Industrial Router (LTE),Catalyst IR1831 (5G),5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
53
- INTEGRATED ROUTER (4G),Cisco,Router,2019,2024,Integrated Router (4G),IR1101 or IR1800 series,5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
54
- IR829GW,Cisco,Router,2021,2024,IR829GW LTE (with WiFi),IR1101 or IR1800 series,5G SA PIM,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
55
- ISR INDUSTRIAL LTE ROUTER,Cisco,Router,3/1/23,2/29/28,ISR Industrial LTE Router,"Catalyst Rugged IR1831 Router (Cat18 LTE, 5G- ready) (Cisco IR1800 series)",5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
56
- ISR INDUSTRIAL ROUTER (LTE),Cisco,Router,7/12/21,7/31/26,ISR Industrial Router (LTE),"Cisco Integrated IR1101 (modular Router LTE, upgradable)",5G SA PIM,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
57
- IX20-PR,Digi,Router,2023,2025,Digi IX20-PR (FirstNet Cat4 router),Digi IX30 (Cat7) TX54 or - FirstNet) for PR (Cat12 FirstNet LTE,IX40,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,FirstNet “PR” SKU EOS | support until ~
58
- IX40,Digi,Router,2023,6/30/25,Digi Ix40 Cat12 Router,Digi IX40 5G (Cat19/5G model),IX40,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,Cat12 model EOS | support until
59
- MC400LP6,Cradlepoint,Modem,2019-10,2025-03,IBR600B/650B Series (LP6 LTE),"S700/S750 Router (5G/ NetCloud LTE, IoT)",MC400 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
60
- LS300,Sierra Wireless (Semtech),Router,2019,2019,AirLink LS300 (3G gateway),RV50,XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
61
- LX60,Sierra Wireless (Semtech),Router,12/31/21,6/30/25,AirLink LX60 (LTE Wireless Router var.) -,AirLink LX60 SKU (consolidated Cat4) (replaces older & Verizon SKUs),XR60,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
62
- M2M,Cisco ,Router,12/2/16,12/31/21,ISR 4G LTE M2M Router (Hardened) | IBR350 (M2M router),Industrial 1R809 Router (4G LTE) /IR829 Wi-Fi variant) (802.11 | S400 Series,R980,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
63
- MBR1200B,Cradlepoint,Router,2018-05,2023-05,MBR1200B (mobile router) | MBR1200B P (branch router),E300 Series | by E300 d superseded by (Cat18),E400,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
64
- MBR1400,Cradlepoint,Router,2019,2021,MBR1400 (mobile router),E300 Series,E400,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
65
- MC400-LP5,Cradlepoint,Modem,5/11/22,2027-05,MC400-LP5 / LP6 Modular LTE Modems,MC400-1200M ( (LTE-A modular Pro Cat12) modem,MC400 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
66
- MC400-LP6,Cradlepoint,Modem,5/11/22,2027-05,MC400-LP5 / LP6 Modular LTE Modems,MC400-1200M ( (LTE-A modular Pro Cat12) modem,MC400 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
67
- MG21,Cisco Meraki,Adapter,2024,2029,MG21 Cellular Gateway (Cat 6 LTE) | MG21/MG21E (LTE Not - FOL,MG41 (Cat 18 LTE) | MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
68
- MG21-HW-NA,Cisco Meraki,Adapter,5/31/25,5/31/29,MG21-HW-NA,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,North America,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-18
69
- MG21-HW-WW,Cisco Meraki,Adapter,5/31/25,5/31/29,MG21-HW-WW,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,Worldwide,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-18
70
- MG21E,Cisco Meraki,Adapter,,,MG21/MG21E (LTE Not - FOL,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
71
- MG21E-HW-NA,Cisco Meraki,Adapter,9/18/24,9/18/29,MG21E-HW-NA,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,North America,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-18
72
- MG21E-HW-WW,Cisco Meraki,Adapter,7/10/24,9/18/29,MG21E-HW-WW,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52,Worldwide,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-18
73
- MG51,Cisco Meraki,Adapter,11/28/25,5/30/30,Cellular MG51 Gateway (LTE Cat 20) | MG51 Cellular Gateway (5G),"Cellular MG51E Gateway (external antenna Cat 20) version, - model current | MG52 (5G Standalone)",MG52,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
74
- MG51-HW,Cisco Meraki,Adapter,11/28/25,5/30/30,MG51-HW,"Cellular MG51E Gateway (external antenna Cat 20) version, - model current | MG52 (5G Standalone)",MG52,Global,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2025-05-30
75
- MG51E-HW,Cisco Meraki,Adapter,11/28/25,5/30/30,MG51E-HW,"Cellular MG51E Gateway (external antenna Cat 20) version, - model current | MG52 (5G Standalone)",MG52,Global,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2025-05-30
76
- MG90,Sierra Wireless (Semtech),Router,2020-07,2023,AirLink MG90 Wireless Router (Cat6),AirLink XR80 (Cat20 LTE & 5G) XR90 (5G) or Mobile Routers,XR80/90,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,approx Pert std su y post-ship
77
- MP70,Sierra Wireless (Semtech),Router,12/31/21,6/30/25,AirLink MP70 (3G/4G) | AirLink MP70 Wireless (Cat6 LTE-A Router),RV50 (LTE) | AirLink MP70 Cat?2 (FirstNet LTE-A Pro Ready) upgraded - model,XR80/90,(not specified) | North America,5G Acceleration PDF | EOS/EOL North America PDF,5G Acceleration EoS Router Refresh Guide.pdf | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
78
- MTE-LAT6-B07-US,MultiTech,Router,,9/10/25,MTE-LAT6-B07-US,rCell 300 Series Intelligent Router,,US,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
79
- MTR-G3-B16,MultiTech,Router,,9/8/21,MTR-G3-B16,MTR-MNG2-B07,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,NRND 2021-04-28; NEOL 2021-09-08
80
- MTR-G3-B16-EU-GB,MultiTech,Router,,9/8/21,MTR-G3-B16-EU-GB,MTR-MNG2-B07-WW,,EU/UK,Web (MultiTech EOL products list),https://multitech.com/eol-products/,NRND 2021-04-28; NEOL 2021-09-08
81
- MTR-MNG2-B07,MultiTech,Router,,9/24/25,MTR-MNG2-B07,rCell 300 Series Intelligent Router,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
82
- MTR-MNG2-B07-WW,MultiTech,Router,,9/24/25,MTR-MNG2-B07-WW,rCell 300 Series Intelligent Router,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
83
- MTR-MNG2-B10,MultiTech,Router,,9/24/25,MTR-MNG2-B10,rCell 300 Series Intelligent Router,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
84
- MTR-MNG2-B10-WW,MultiTech,Router,,9/24/25,MTR-MNG2-B10-WW,rCell 300 Series Intelligent Router,,Global,Web (MultiTech EOL products list),https://multitech.com/eol-products/,
85
- MX64,Cisco Meraki,Router,2022,2027,MX64 Security Appliance,,MX67 + MG52,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
86
- RV50,Sierra Wireless (Semtech),Router,2019,2022,AirLink RV50 (LTE gateway),RV50X (LTE Advanced),XR60,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
87
- RV50X,Sierra Wireless (Semtech),Router,12/31/25,6/30/26,AirLink RV50X (SKU 1103052) - EOL due to component availability | AirLink Raven Wireless RV50X (LTE Cat6) GSA,"RX55, RV55, XR60 (per distributor) | AirLink RX55 LTE-A Router (Cat7/Cat13, new rugged series)",XR60,NA/EMEA | North America,Web (Distributor listing) | EOS/EOL North America PDF,https://www.westbase.io/shop/product/sierra-wireless-rv50x-sierra-wireless-rv50x-naemea | End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,Dates are Last Time Buy / Last Time Ship from listing | GSA SKU EOS | (Std RV50/ - still RV50xX supported
88
- TX54,Digi,Router,2023,2028,Digi TX54 (Cat11 LTE-A Router),Digi TX54 (Cat12 Responder” “Primary SKU) TX54 5G or - updated models,TX54 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,global SKUs EOS | std. 5-yr support
89
- TX64,Digi,Router,2023,2028,Digi TX64 (Cat11/ Cat18 Router),"Digi TX64 5G (Cat20, 5G Rail latest variants) - model",TX64 5G,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,Cat11 & Cat18 SKUs EOS | support until
90
- WR11,Digi,Router,2018,2023,TransPort WR11 (LTE router),IX10 (Industrial LTE),IX40,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,
91
- WR11-XT,Digi,Router,9/30/21,2022-03,Digi WR11 XT (LTE Router),Digi IX10 IX20 (LTE or Cat-M1/Cat4 routers),IX40,North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,
92
- WR21,Digi,Router,9/30/21,2022-03,TransPort WR21 (3G/4G Router) | TransPort WR21 (4G router),Digi Industrial IX20 (DAL Router OS) 5° | IX20 (Industrial LTE),IX40,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
93
- WR44,Digi,Router,2016,2021,TransPort WR44 (4G mobile router),Series (IX15/IX20 or TX54),IX40,(not specified),5G Acceleration PDF,5G Acceleration EoS Router Refresh Guide.pdf,IX/TX
94
- WR44-R,Digi,Router,2019,2023,TransPort WR44- R/WR44 RR (Rugged),"Mobile TX54/TX64 Routers (Cat11/Cat18, options) ","Mobile TX54/TX64 Routers (Cat11/Cat18, options) 5G",North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,varies ~ by SKU; NRND in | est.5- support yr window
95
- WR44-RR,Digi,Router,2019,2023,TransPort WR44- R/WR44 RR (Rugged),"Mobile TX54/TX64 Routers (Cat11/Cat18, options) ","Mobile TX54/TX64 Routers (Cat11/Cat18, options) 5G",North America,EOS/EOL North America PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf,varies ~ by SKU; NRND in | est.5- support yr window
96
- Z3-HW,Cisco Meraki,Router,9/4/24,9/4/29,Z3-HW,Meraki Z4C,Z4C+MG52,Global,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-04
97
- Z3C,Cisco Meraki,Router,9/4/24,9/4/29,Teleworker Z3C Gateway (LTE) - SKU | Z3C Teleworker Gateway (LTE),Meraki Z4C,Z4C+MG52,North America | (not specified),EOS/EOL North America PDF | 5G Acceleration PDF,End-of-Sale (EOS) and End-of-Life (EOL) Cellular Routers_Modems (North America).pdf | 5G Acceleration EoS Router Refresh Guide.pdf,
98
- Z3C-HW-NA,Cisco Meraki,Router,9/4/24,9/4/29,Z3C-HW-NA,Meraki Z4C,Z4C+MG52,North America,Web (Meraki EOL list),https://documentation.meraki.com/.../Returns_(RMAs)_Warranties_and_End-of-Life_Information,Announcement 2024-03-04
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SKU,manufacturer,Device Type,end_of_sale,end_of_life,suggested_replacement,advanced_5g_option
2
+ 1002-CMF4-OUS,Digi,Router,2024,2027,NA,unknown
3
+ ASB-EX15-WC18-GLB,Digi,Router,2024,2027,EX15 w/DIGI 360,EX50
4
+ ASB-EX15-WX04-OUS,Digi,Router,2024,2027,EX15 w/DIGI 360,EX50
5
+ ASB-EX15-WX06-GLB,Digi,Router,2024,2027,EX15 w/DIGI 360,EX50
6
+ ASB-EX15-WX06-OUS,Digi,Router,2024,2027,EX15 w/DIGI 360,EX50
7
+ ASB-EX15-WX11-OUS,Digi,Router,2024,2027,EX15 w/DIGI 360,EX50
8
+ ASB-EX15-XX04-OUS,Digi,Router,2024,2027,EX15 w/DIGI 360,EX50
9
+ ASB-EX15-XX06-OUS,Digi,Router,2024,2027,EX15 w/DIGI 360,EX50
10
+ ASB-EX15-XX07-OUS,Digi,Router,2024,2027,EX15 w/DIGI 361,EX50
11
+ ASB-EX15-XX11-OUS,Digi,Router,2024,2027,EX15 w/DIGI 362,EX50
12
+ ASB-EX15-XX18-OUS-1,Digi,Router,2024,2027,EX15 w/DIGI 363,EX50
13
+ ASB-EX15-XXG4-GLB,Digi,Router,2024,2027,EX15 w/DIGI 364,EX50
14
+ IX20-W000-GLB,Digi,Router,2024,2027,IX20 W/DIGI 360,IX40
15
+ TX54-A106,Digi,Router,2024,2027,NA,TX54 5G
16
+ TX54-A112,Digi,Router,2024,2027,NA,TX54 5G
17
+ TX54-A206,Digi,Router,2024,2027,NA,TX54 5G
18
+ TX64-A121,Digi,Router,2024,2027,NA,TX64 5G
19
+ TX64-A141,Digi,Router,2024,2027,NA,TX64 5G
20
+ TX64-A161-PR,Digi,Router,2024,2027,NA,TX64 5G
21
+ TX64-R210-PR,Digi,Router,2024,2027,NA,TX64 5G
22
+ 6300-CX,Digi (Accelerated Concepts),Router,2022,2024,EX15 w/DIGI 364,EX50
23
+ 6300-DX,Digi (Accelerated Concepts),Router,2022,2024,EX15 w/DIGI 364,EX50
24
+ 6300-MX,Digi (Accelerated Concepts),Router,2022,2027,EX15 w/DIGI 364,EX50
25
+ 2100LE-VZF,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E3000-5GB
26
+ 2100LE2-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E3000-5GB
27
+ 2100LP-ATF,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E3000-5GB
28
+ 2100LP-F,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E3000-5GB
29
+ 2100LP2-EUF,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E3000-5GB
30
+ 2100LP2-UKF,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E3000-5GB
31
+ 2100LP3-AU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
32
+ 2100LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
33
+ 2100LP3-UK,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
34
+ 2100LP5-AU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
35
+ 2100LP5-UK,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
36
+ 2100LP6-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
37
+ 2100LP6-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
38
+ 2100LP6-UK,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
39
+ 2100LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
40
+ 2100LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
41
+ 2100LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
42
+ 2100LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
43
+ AER1600LP4,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,E300-C18B,E400-5GE-AM
44
+ AER1600LP5,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,E300-C18B,E400-5GE-AM
45
+ AER1600LP6,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,E300-C18B,E400-5GE-AM
46
+ AER1600LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400-5GE-AM
47
+ AER1600LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400-5GE-AM
48
+ AER1600LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400-5GE-AM
49
+ AER1600LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400-5GE-AM
50
+ AER1600NM,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,E300-C18B,E400-5GE-AM
51
+ AER1650LP4,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,E300-C18B,E400-5GE-AM
52
+ AER1650LP5,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,E300-C18B,E400-5GE-AM
53
+ AER1650LP6,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,E300-C18B,E400-5GE-AM
54
+ AER1650LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400-5GE-AM
55
+ AER1650LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400-5GE-AM
56
+ AER1650LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400-5GE-AM
57
+ AER1650LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400-5GE-AM
58
+ AER1650NM,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,E300-C18B,E400-5GE-AM
59
+ AER2200-1200M,Ericsson Enterprise Wireless (Cradlepoint),Router,2020,2025,E300-C18B,E3000-5GB
60
+ AER2200-1200M-B-AP,Ericsson Enterprise Wireless (Cradlepoint),Router,2022,2027,E300-C18B,E3000-5GB
61
+ AER2200-1200M-B-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2022,2027,E300-C18B,E3000-5GB
62
+ AER2200-1200M-B-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2022,2027,E300-C18B,E3000-5GB
63
+ AER2200-600M-AP,Ericsson Enterprise Wireless (Cradlepoint),Router,2022,2027,E300-C18B,E3000-5GB
64
+ AER2200-600M-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2022,2027,E300-C18B,E3000-5GB
65
+ AER2200-600M-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2022,2027,E300-C18B,E3000-5GB
66
+ AER3100LP3-AU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
67
+ AER3100LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
68
+ AER3100LP3-UK,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
69
+ AER3100LP5-AU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
70
+ AER3100LP5-UK,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
71
+ AER3100LP6-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
72
+ AER3100LP6-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
73
+ AER3100LP6-UK,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
74
+ AER3100LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
75
+ AER3100LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
76
+ AER3100LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
77
+ AER3100LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
78
+ AER3150LP3-AU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
79
+ AER3150LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
80
+ AER3150LP3-UK,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
81
+ AER3150LP5-AU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
82
+ AER3150LP6-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
83
+ AER3150LP6-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
84
+ AER3150LP6-UK,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
85
+ AER3150LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
86
+ AER3150LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
87
+ AER3150LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
88
+ AER3150LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E3000-5GB
89
+ CBA250,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2011,2011,S750-C4D,W1850-5GC
90
+ CBA550,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2024,2029,L950,W1850-5GC
91
+ CBA550-150M-D,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2024,2029,L950-C7A,W1850-5GC
92
+ CBA550-150M-E,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2024,2029,L950-C7B,W1850-5GC
93
+ CBA550-150M-E-G,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2024,2029,L950-C7B,W1850-5GC
94
+ CBA750B,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2016,2021,L950-C7C,W1850-5GC
95
+ CBA750B-INTL,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2016,2021,L950-C7D,W1850-5GC
96
+ CBA750B-LE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2015,2020,L950-C7C,W1850-5GC
97
+ CBA750B-LE2-SP,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2015,2020,L950-C7C,W1850-5GC
98
+ CBA750B-LP,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2015,2020,L950-C7C,W1850-5GC
99
+ CBA750B-LP-AT,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2015,2020,L950-C7C,W1850-5GC
100
+ CBA750B-LP2-EU,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2014,2020,L950-C7D,W1850-5GC
101
+ CBA750B-LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2016,2021,L950-C7D,W1850-5GC
102
+ CBA750B-LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2016,2021,L950-C7C,W1850-5GC
103
+ CBA750B-LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2016,2021,L950-C7C,W1850-5GC
104
+ CBA750B-LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2016,2021,L950-C7C,W1850-5GC
105
+ CBA750B-LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2016,2021,L950-C7C,W1850-5GC
106
+ CBA750B-W-SP,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2014,2016,L950-C7C,W1850-5GC
107
+ CBA750E-SP,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2012,2013,L950-C7C,W1850-5GC
108
+ CBA750E-VZ,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2012,2013,L950-C7C,W1850-5GC
109
+ CBA750LE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2013,2018,L950-C7C,W1850-5GC
110
+ CBA750LP,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2013,2018,L950-C7C,W1850-5GC
111
+ CBA750LP-AT,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2013,2018,L950-C7C,W1850-5GC
112
+ CBA750W-SP,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2013,2018,L950-C7C,W1850-5GC
113
+ CBA850,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,L950,W1850-5GC
114
+ CBA850-1200M,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2020,2025,L950-C7C,W1850-5GC
115
+ CBA850-1200M-B,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,L950-C7C,W1850-5GC
116
+ CBA850-1200M-B-AP,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,L950-C7B,W1850-5GC
117
+ CBA850-1200M-B-EU,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,L950-C7B,W1850-5GC
118
+ CBA850LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2018,2023,L950-C7D,W1850-5GC
119
+ CBA850LP4,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2020,2025,L950-C7C,W1850-5GC
120
+ CBA850LP5,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,L950-C7B,W1850-5GC
121
+ CBA850LP6,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,L950-C7A,W1850-5GC
122
+ CBA850LP6-EU,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,L950-C7B,W1850-5GC
123
+ CBA850LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2018,2023,L950-C7C,W1850-5GC
124
+ CBA850LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2018,2023,L950-C7C,W1850-5GC
125
+ CBA850LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2018,2023,L950-C7C,W1850-5GC
126
+ CBA850LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2018,2023,L950-C7C,W1850-5GC
127
+ CR4250,Ericsson Enterprise Wireless (Cradlepoint),Router,2023,2028,NA,E3000-5GB
128
+ CR4250-PoE,Ericsson Enterprise Wireless (Cradlepoint),Router,2023,2028,NA,E3000-5GB
129
+ CTR35,Ericsson Enterprise Wireless (Cradlepoint),Router,2013,2013,S750,R980
130
+ CTR350,Ericsson Enterprise Wireless (Cradlepoint),Router,2010,2011,S750,R980
131
+ CTR500,Ericsson Enterprise Wireless (Cradlepoint),Router,2011,2011,S750,R980
132
+ E100-5GC,Ericsson Enterprise Wireless (Cradlepoint),Router,2026,2031,NA,E400
133
+ E100-C4D,Ericsson Enterprise Wireless (Cradlepoint),Router,2026,2031,NA,E400
134
+ E100-C7C,Ericsson Enterprise Wireless (Cradlepoint),Router,2026,2031,NA,E400
135
+ E102,Ericsson Enterprise Wireless (Cradlepoint),Router,2026,2031,NA,E400
136
+ E102-C7C,Ericsson Enterprise Wireless (Cradlepoint),Router,2026,2031,NA,E400
137
+ E102-C7D,Ericsson Enterprise Wireless (Cradlepoint),Router,2026,2031,NA,E400
138
+ E110-C4D,Ericsson Enterprise Wireless (Cradlepoint),Router,2026,2031,NA,unknown
139
+ E300-C18B,Ericsson Enterprise Wireless (Cradlepoint),Router,2025,2030,NA,E400-5GE-AM
140
+ E300-C4D,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,E300-C7C,unknown
141
+ E3000-C18B,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,unknown,E3000-5GB
142
+ E320,Ericsson Enterprise Wireless (Cradlepoint),Router,2025,2030,unknown,unknown
143
+ E3205GB,Ericsson Enterprise Wireless (Cradlepoint),Router,2025,2030,,unknown
144
+ IBR1100,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2024,IBR1700,unknown
145
+ IBR1100LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,IBR1700-1200M-B,IBR1900
146
+ IBR1100LP6-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,IBR1700-1200M-B,IBR1900
147
+ IBR1100LP6-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2024,IBR1700-1200M-B,IBR1900
148
+ IBR1100LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,IBR1700-1200M-B,IBR1900
149
+ IBR1100LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,IBR1700-1200M-B,IBR1900
150
+ IBR1100LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,IBR1700-1200M-B,IBR1900
151
+ IBR1100LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,IBR1700-1200M-B,IBR1900
152
+ IBR1150,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,IBR1900
153
+ IBR1150LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,IBR1900
154
+ IBR1150LP6-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,IBR1900
155
+ IBR1150LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,IBR1900
156
+ IBR1150LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,IBR1900
157
+ IBR1150LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,IBR1900
158
+ IBR1150LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,IBR1900
159
+ IBR1700-1200M,Ericsson Enterprise Wireless (Cradlepoint),Router,2020,2025,R920,IBR1900
160
+ IBR1700-600M,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,unknown,R1900-5GB
161
+ IBR200-10M(-VZ),Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S400-C6-EA,r980
162
+ IBR200-10M-B(-AT),Ericsson Enterprise Wireless (Cradlepoint),Router,2025,2030,S400,r980
163
+ IBR200-10M-C-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2021,2026,S400-C6-EA,r980
164
+ IBR200-10M-D(-EU),Ericsson Enterprise Wireless (Cradlepoint),Router,2025,2030,S400-C6-EA,r980
165
+ IBR200-10M-PWM,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S400-C6-EA,r980
166
+ IBR350L-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2017,2022,S700,r980
167
+ IBR350LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700,r980
168
+ IBR350LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700,r980
169
+ IBR350LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700,r980
170
+ IBR350LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700,r980
171
+ IBR350P2,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700,r980
172
+ IBR350P2-INTL,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700,r980
173
+ IBR600B,Ericsson Enterprise Wireless (Cradlepoint),Router,2020,2025,S700,r980
174
+ IBR600B-LP4,Ericsson Enterprise Wireless (Cradlepoint),Router,2020,2025,S700,r980
175
+ IBR600C,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S700,r980
176
+ IBR600C-150M,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S700,r980
177
+ IBR600C-150M-B,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S700-C4E,r980
178
+ IBR600C-150M-C,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S700-C4E,r980
179
+ IBR600C-150M-D,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S700-C4D,r980
180
+ IBR600C-LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2023,S700,r980
181
+ IBR600C-LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2023,S700,r980
182
+ IBR600C-LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2023,R920-C7B,r980
183
+ IBR600C-LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2023,S700-C4D,r980
184
+ IBR600E-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700,r980
185
+ IBR600E-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700-C4D,r980
186
+ IBR600LE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700,r980
187
+ IBR600LE-VZ-PWD,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700-C4D,r980
188
+ IBR600LE2-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700,r980
189
+ IBR600LP,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700-C4D,r980
190
+ IBR600LP-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700-C4D,r980
191
+ IBR600LP-AT-PWD,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700-C4D,r980
192
+ IBR600LP2-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,S700-C4D,r980
193
+ IBR600LP2-EU-PWD,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,S700-C4D,r980
194
+ IBR600LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700-C4D,r980
195
+ IBR600LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700-C4D,r980
196
+ IBR600LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700-C4D,r980
197
+ IBR600LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700,r980
198
+ IBR600LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700-C4D,r980
199
+ IBR600NM,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700,r980
200
+ IBR600NM-INTL,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S700,r980
201
+ IBR600P,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700-C4D,r980
202
+ IBR600P-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700-C4D,r980
203
+ IBR600P-INTL,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S700,r980
204
+ IBR650B,Ericsson Enterprise Wireless (Cradlepoint),Router,2020,2025,S750,r980
205
+ IBR650B-LP4,Ericsson Enterprise Wireless (Cradlepoint),Router,2020,2025,S750-C4D,r980
206
+ IBR650C,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S750,r980
207
+ IBR650C-150M,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S750-C4D,r980
208
+ IBR650C-150M-D-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,S750-C4D,r980
209
+ IBR650C-LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2023,IBR650C-150M-D-NA,r980
210
+ IBR650C-LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2023,IBR650C-150M-D-NA,r980
211
+ IBR650C-LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2023,IBR900-600M,r980
212
+ IBR650C-LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2019,2023,IBR650C-150M-D-NA,r980
213
+ IBR650LE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,R920-C7B,r980
214
+ IBR650LE2-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S750,r980
215
+ IBR650LP-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S750,r980
216
+ IBR650LP2-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,S750,r980
217
+ IBR650LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S750,r980
218
+ IBR650LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S750,r980
219
+ IBR650LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S750,r980
220
+ IBR650LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S750,r980
221
+ IBR650NM,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920-C7B,r980
222
+ IBR650NM-INTL,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,S750,r980
223
+ IBR650P,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S750,r980
224
+ IBR650P-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S750,r980
225
+ IBR650P-INTL,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S750,r980
226
+ IBR650W,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,S750,r980
227
+ IBR900,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,R920,r980
228
+ IBR900-1200M,Ericsson Enterprise Wireless (Cradlepoint),Router,2020,2025,R920,r980
229
+ IBR900-1200M-B,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,R920-C7B,r980
230
+ IBR900-600M,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,R920-C7B,r980
231
+ IBR900-FIPS,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,R920-FIPS,r980
232
+ IBR900-FIPS-1200M-B,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,R920-FIPS-C7A,r980
233
+ IBR900-FIPS-600M,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,R920-FIPS-C7A,r980
234
+ IBR900LP5-AP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
235
+ IBR900LP6-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
236
+ IBR900LP6-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
237
+ IBR900LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
238
+ IBR900LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
239
+ IBR900LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
240
+ IBR900LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
241
+ IBR900NM,Ericsson Enterprise Wireless (Cradlepoint),Router,2024,2029,R920-C7B,r980
242
+ IBR950,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
243
+ IBR950LP6-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
244
+ IBR950LP6-NA,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
245
+ IBR950LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
246
+ IBR950LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
247
+ IBR950LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,R920,r980
248
+ L950-C7B,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2025,2030,S400-C6-EA,W1850-5GC
249
+ MBR1000,Ericsson Enterprise Wireless (Cradlepoint),Router,2011,2011,E300-C18B,E400
250
+ MBR1100,Ericsson Enterprise Wireless (Cradlepoint),Router,2010,2011,E300-C18B,E400
251
+ MBR1200B,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400
252
+ MBR1200B-INTL,Ericsson Enterprise Wireless (Cradlepoint),Router,2018,2023,E300-C18B,E400
253
+ MBR1200CP,Ericsson Enterprise Wireless (Cradlepoint),Router,2011,2011,E300-C18B,E400
254
+ MBR1400,Ericsson Enterprise Wireless (Cradlepoint),Router,2016,2021,E300-C18B,E400
255
+ MBR1400-INTL,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E400
256
+ MBR1400E-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2012,2013,E300-C18B,E400
257
+ MBR1400E-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2012,2013,E300-C18B,E400
258
+ MBR1400LE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,E300-C18B,E400
259
+ MBR1400LE2-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,E300-C18B,E400
260
+ MBR1400LP,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,E300-C18B,E400
261
+ MBR1400LP-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2015,2020,E300-C18B,E400
262
+ MBR1400LP2-EU3,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E400
263
+ MBR1400LP3-EU,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E400
264
+ MBR1400LPE-AT,Ericsson Enterprise Wireless (Cradlepoint),Router,2016,2021,E300-C18B,E400
265
+ MBR1400LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Router,2016,2021,E300-C18B,E400
266
+ MBR1400LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2016,2021,E300-C18B,E400
267
+ MBR1400LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Router,2016,2021,E300-C18B,E400
268
+ MBR1400W-SP,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2016,E300-C18B,E400
269
+ MBR1400v2,Ericsson Enterprise Wireless (Cradlepoint),Router,2016,2021,E300-C18B,E400
270
+ MBR800,Ericsson Enterprise Wireless (Cradlepoint),Router,2011,2011,E300-C18B,E400
271
+ MBR90,Ericsson Enterprise Wireless (Cradlepoint),Router,2011,2011,E300-C18B,E400
272
+ MBR900,Ericsson Enterprise Wireless (Cradlepoint),Router,2011,2011,E300-C18B,E400
273
+ MBR95,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E400
274
+ MBR95-INTL,Ericsson Enterprise Wireless (Cradlepoint),Router,2014,2019,E300-C18B,E400
275
+ MC100E-SP,Ericsson Enterprise Wireless (Cradlepoint),Modem,2012,2013,MC400-1200M-B,MC400-5G
276
+ MC100E-VZ,Ericsson Enterprise Wireless (Cradlepoint),Modem,2012,2013,MC400-1200M-B,MC400-5G
277
+ MC100W,Ericsson Enterprise Wireless (Cradlepoint),Modem,2014,2019,MC400-1200M-B,MC400-5G
278
+ MC200LE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Modem,2015,2020,MC400-1200M-B,MC400-5G
279
+ MC200LE2-SP,Ericsson Enterprise Wireless (Cradlepoint),Modem,2015,2020,MC400-1200M-B,MC400-5G
280
+ MC200LP,Ericsson Enterprise Wireless (Cradlepoint),Modem,2015,2020,MC400-1200M-B,MC400-5G
281
+ MC200LP-AT,Ericsson Enterprise Wireless (Cradlepoint),Modem,2015,2020,MC400-1200M-B,MC400-5G
282
+ MC300LE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Modem,2014,2019,MC400-1200M-B,MC400-5G
283
+ MC300LP,Ericsson Enterprise Wireless (Cradlepoint),Modem,2014,2019,MC400-1200M-B,MC400-5G
284
+ MC300LP-AT,Ericsson Enterprise Wireless (Cradlepoint),Modem,2014,2019,MC400-1200M-B,MC400-5G
285
+ MC300LP2-EU,Ericsson Enterprise Wireless (Cradlepoint),Modem,2014,2019,MC400-1200M-B,MC400-5G
286
+ MC400-1200M,Ericsson Enterprise Wireless (Cradlepoint),Modem,2020,2025,MC400-1200M-B,MC400-5G
287
+ MC400-600M-C-AT,Ericsson Enterprise Wireless (Cradlepoint),Modem,2020,2025,MC400-1200M-B,MC400-5G
288
+ MC400LE2-SP,Ericsson Enterprise Wireless (Cradlepoint),Modem,2014,2019,MC400-1200M-B,MC400-5G
289
+ MC400LP3-EU-ARC,Ericsson Enterprise Wireless (Cradlepoint),Modem,2016,2021,MC400-1200M-B,MC400-5G
290
+ MC400LP4,Ericsson Enterprise Wireless (Cradlepoint),Modem,2020,2025,MC400-1200M-B,MC400-5G
291
+ MC400LP5,Ericsson Enterprise Wireless (Cradlepoint),Modem,2022,2027,MC400-1200M-B,MC400-5G
292
+ MC400LP6,Ericsson Enterprise Wireless (Cradlepoint),Modem,2022,2027,MC400-1200M-B,MC400-5G
293
+ MC400LPE-GN,Ericsson Enterprise Wireless (Cradlepoint),Modem,2018,2023,MC400-1200M-B,MC400-5G
294
+ MC400LPE-GN-ARC,Ericsson Enterprise Wireless (Cradlepoint),Modem,2016,2021,MC400-1200M-B,MC400-5G
295
+ MC400LPE-SP,Ericsson Enterprise Wireless (Cradlepoint),Modem,2018,2023,MC400-1200M-B,MC400-5G
296
+ MC400LPE-SP-ARC,Ericsson Enterprise Wireless (Cradlepoint),Modem,2016,2021,MC400-1200M-B,MC400-5G
297
+ MC400LPE-VZ,Ericsson Enterprise Wireless (Cradlepoint),Modem,2018,2023,MC400-1200M-B,MC400-5G
298
+ MC400LPE-VZ-ARC,Ericsson Enterprise Wireless (Cradlepoint),Modem,2016,2021,MC400-1200M-B,MC400-5G
299
+ W1850-5GB,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2024,2029,NA,W1850-5GC
300
+ W2000,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,NA,W1850-5GC
301
+ W2000-5GA,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,NA,W1850-5GC
302
+ W2000-5GB,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,NA,W1850-5GC
303
+ W2005,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2024,2029,NA,W1855-5GC
304
+ W2005-5GA,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2022,2027,NA,W1855-5GC
305
+ W2005-5GB,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2024,2029,NA,W1855-5GC
306
+ W4005,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2025,2030,NA,NA
307
+ W4005-5GB,Ericsson Enterprise Wireless (Cradlepoint),Adapter,2025,2030,NA,NA
308
+ BPL-021X-LTE-US-T,Peplink,Router,2023,TBD,unknown,unknown
309
+ BPL-031-LTE-US-T,Peplink,Router,2023,TBD,unknown,unknown
310
+ BPL-031-LTEA-W-T,Peplink,Router,2023,TBD,unknown,unknown
311
+ BPL-310X-GLTE-G-T,Peplink,Router,2023,TBD,unknown,unknown
312
+ EXM-2GLTE-G,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
313
+ EXM-2X5GD,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
314
+ EXM-MBX-T2-5GD,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
315
+ MAX-600,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
316
+ MAX-BR1-AE-IP55,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
317
+ MAX-BR1-ENT,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
318
+ MAX-BR1-ENT-LTE,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
319
+ MAX-BR1-ESN-LTEA,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
320
+ MAX-BR1-LTE-US,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
321
+ MAX-BR1-LTEA-W-T,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
322
+ MAX-BR1-M2M,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
323
+ MAX-BR1-M2M-LTE,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
324
+ MAX-BR1-MINI,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
325
+ MAX-BR1-MK2,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
326
+ MAX-BR1-PRO-LTE,Peplink,Router,2023,TBD,Max BR 1 Pro 5G,Max BR 1 Pro 5G
327
+ MAX-BR1-SLIM,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
328
+ MAX-BR1-SLIM-LTE,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
329
+ MAX-BR2-AE-IP55,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
330
+ MAX-BR2-AE-SF,Peplink,Router,2023,TBD,Max BR 1 Mini HW3,Max BR 1 Mini 5G
331
+ MAX-HD1-DOM-M,Peplink,Router,2023,TBD,NA,HD Dome 5G
332
+ MAX-HD2-DOM-M,Peplink,Router,2023,TBD,NA,HD Dome 5G
333
+ MAX-HD2-LTE-US-T,Peplink,Router,2023,TBD,NA,HD Dome 5G
334
+ MAX-HD2-LTEA,Peplink,Router,2023,TBD,NA,HD Dome 5G
335
+ MAX-HD2-LTEA-W,Peplink,Router,2023,TBD,NA,HD Dome 5G
336
+ MAX-HD2-LTEA-W-T,Peplink,Router,2023,TBD,NA,HD Dome 5G
337
+ MAX-HD2-M-IP67,Peplink,Router,2023,TBD,NA,HD Dome 5G
338
+ MAX-HD2-M-LTE,Peplink,Router,2023,TBD,NA,HD Dome 5G
339
+ MAX-HD2-MBX,Peplink,Router,2023,TBD,NA,HD Dome 5G
340
+ MAX-HD2-MFA,Peplink,Router,2023,TBD,NA,HD Dome 5G
341
+ MAX-HD2-MINI-LTE,Peplink,Router,2023,TBD,NA,HD Dome 5G
342
+ MAX-HD2-MINI-LTE-US-T,Peplink,Router,2023,TBD,NA,HD Dome 5G
343
+ MAX-HD4-LTE-US-T,Peplink,Router,2023,TBD,NA,HD Dome 5G
344
+ MAX-HD4-LTEA,Peplink,Router,2023,TBD,NA,HD Dome 5G
345
+ MAX-HD4-MBX,Peplink,Router,2023,TBD,NA,HD Dome 5G
346
+ MAX-HD4-MFA-LTE,Peplink,Router,2023,TBD,NA,HD Dome 5G
347
+ MAX-OTG-U4,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
348
+ MAX-OTG-U4-SF,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
349
+ MAX-TST-5GD-T,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
350
+ MAX-TST-DUO,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
351
+ MAX-TST-DUO-LC,Peplink,Router,2023,TBD,NA,Max BR2 Pro 5G
352
+ MAX-TST-GLTE-G-T,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
353
+ MAX-TST-LTEA-R-T,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
354
+ MAX-TST-LTEA-W-T,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
355
+ MAX-TST-MINI-LTE,Peplink,Router,2023,TBD,NA,Max BR 1 Mini 5G
356
+ MAX-TST-PROE,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
357
+ MAX-TST-US-T,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
358
+ UBR-LTE-US-T-PRM,Peplink,Router,2023,TBD,NA,Max BR 1 Pro 5G
359
+ WR11,Digi,Router,2022,2022, IX10,IX40
360
+ WR11-XT,Digi,Router,2021,2022, IX10,IX40
361
+ WR21,Digi,Router,2022,2022, IX20,IX40
362
+ WR44,Digi,Router,2023,2023, TX64,TX64
363
+ WR44-R,Digi,Router,2023,2023, TX64,TX64 5G
364
+ WR44-RR,Digi,Router,2023,2023, TX64,TX64 5G
365
+ WR11,Digi,Router,2022,2023, IX10, IX40
366
+ WR11-XT,Digi,Router,2021,2023, IX10, IX40
367
+ WR21,Digi,Router,2016,2024, IX20, IX40
368
+ WR44,Digi,Router,2016,2021, TX64, TX64 5G
369
+ WR44-R,Digi,Router,2016,2021, TX64, TX64 5G
370
+ WR44-RR,Digi,Router,2023,2025, TX64, TX64 5G Rail
371
+ EX450,Semtech (Sierra Wireless),Router,2019,2023,RX55, XR60
372
+ ES450,Semtech (Sierra Wireless),Router,2021,2025, LX60, XR60
373
+ GX400,Semtech (Sierra Wireless),Router,2016,2021,RX55, XR60
374
+ GX440,Semtech (Sierra Wireless),Router,2016,2021,RX55, XR60
375
+ GX450,Semtech (Sierra Wireless),Router,2020,2023, RV55, XR60
376
+ LS300,Semtech (Sierra Wireless),Router,TBD,2019,RX55, XR60
377
+ LX60,Semtech (Sierra Wireless),Router,TBD,TBD,RX55, XR60
378
+ MG90,Semtech (Sierra Wireless),Router,2025,2028, XR80 (LTE Cat 20), XR80
379
+ MP70,Semtech (Sierra Wireless),Router,2021,2025, MP70 (Cat 12), XR80
380
+ RV50,Semtech (Sierra Wireless),Router,2019,2022,RX55, XR60
381
+ RV50X,Semtech (Sierra Wireless),Router,2025,2030,RX55, XR60
382
+ RUT240,Teltonika,Router,2023,2028,RUT241,Teltonika RUTX50
383
+ RUT360,Teltonika,Router,2023,2028,RUT361,Teltonika RUTX50
384
+ 3GHWIC,Cisco,Modem,2012,2017,NIM-LTEA-EA,P-5GS6-R16SA-GL
385
+ 4G LTE EHWIC MODULES (ISR,Cisco,Modem,2019,2024,NIM-LTEA-EA,P-5GS6-R16SA-GL
386
+ 4GLTE2.0EHWIC,Cisco,Modem,2019,2024,NIM-LTEA-EA,P-5GS6-R16SA-GL
387
+ 881G,Cisco,Router,2019,2024,ISR 1100 Series,Catalyst Cellular Gateway CG522-E
388
+ 887,Cisco,Router,2019,2024,ISR 1100 Series,Catalyst Cellular Gateway CG522-E
389
+ 896,Cisco,Router,2019,2024,ISR 1100 Series,Catalyst Cellular Gateway CG522-E
390
+ 897,Cisco,Router,2019,2024,ISR 1100 Series,Catalyst Cellular Gateway CG522-E
391
+ 829 Industrial Router,Cisco,Router,2023,2028,IR1101 + LTE PIM,IR1831.+ 5G PIM
392
+ 809 Hardended,Cisco,Router,2021,2025,IR1101 + LTE PIM,IR1831.+ 5G PIM
393
+ IR829 (LTE + WIFI),Cisco,Router,2021,2024,IR1101 + LTE PIM,IR1831.+ 5G PIM
394
+ 819 Integrated Router (4G),Cisco,Router,2019,2024,IR1101 + LTE PIM,IR1831.+ 5G PIM
395
+ 4G LTE EHWIC (ISR G2),Cisco,Modem,2019,2024,LTE NIMs,5G PIM
396
+ 3G HWIC & 4G LTE 2.0 EHWIC,Cisco,Modem,2017,2024,LTE EHWIC,IR1831.+ 5G PIM
397
+ 819 Hardended (HSPA+/EVDO),Cisco,Router,2016,2021,IR1101 + LTE PIM,IR1831.+ 5G PIM
398
+ MG21,Cisco (Meraki),Adapter,2024,2029,MG41 (Cat 18 LTE) | MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52
399
+ MG21-HW-NA,Cisco (Meraki),Adapter,2025,2029,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52
400
+ MG21-HW-WW,Cisco (Meraki),Adapter,2025,2029,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52
401
+ MG21E,Cisco (Meraki),Adapter,2024,2029,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52
402
+ MG21E-HW-NA,Cisco (Meraki),Adapter,2024,2029,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52
403
+ MG21E-HW-WW,Cisco (Meraki),Adapter,2024,2029,MG41 (Cat18 LTE) or - (5G-capable) MG551E still (Note: MG21 series supported of 2025) as,MG52
404
+ MG51,Cisco (Meraki),Adapter,2025,1930,"Cellular MG51E Gateway (external antenna Cat 20) version,- model current | MG52 (5G Standalone)",MG52
405
+ MG51-HW,Cisco (Meraki),Adapter,2025,1930,"Cellular MG51E Gateway (external antenna Cat 20) version,- model current | MG52 (5G Standalone)",MG52
406
+ MG51E-HW,Cisco (Meraki),Adapter,2025,1930,"Cellular MG51E Gateway (external antenna Cat 20) version,- model current | MG52 (5G Standalone)",MG52
407
+ MX64,Cisco (Meraki),Router,1905,1905,MX67C,MX67 + MG52
408
+ Z3-HW,Cisco (Meraki),Router,2024,2029, Z4C,Z4C+MG52
409
+ Z3C,Cisco (Meraki),Router,2024,2029, Z4C,Z4C+MG52
410
+ Z3C-HW-NA,Cisco (Meraki),Router,2024,2029, Z4C,Z4C+MG52
411
+ MTE-LAT6-B07-US,MultiTech,Router,2025,2025,MTE-LAT6-B07-US,rCell 300 Series Intelligent Router
412
+ MTR-G3-B16,MultiTech,Router,2021,2021,MTR-G3-B16,MTR-MNG2-B07
413
+ MTR-G3-B16-EU-GB,MultiTech,Router,2021,2021,MTR-G3-B16-EU-GB,MTR-MNG2-B07-WW
414
+ MTR-MNG2-B07,MultiTech,Router,2025,2025,MTR-MNG2-B07,rCell 300 Series Intelligent Router
415
+ MTR-MNG2-B07-WW,MultiTech,Router,2025,2025,MTR-MNG2-B07-WW,rCell 300 Series Intelligent Router
416
+ MTR-MNG2-B10,MultiTech,Router,2025,2025,MTR-MNG2-B10,rCell 300 Series Intelligent Router
417
+ MTR-MNG2-B10-WW,MultiTech,Router,2025,2025,MTR-MNG2-B10-WW,rCell 300 Series Intelligent Router
418
+ ACM5004-LR-E,Opengear,Router,2016,2021,ACM5000 family (ACM5004-LR-E etc),ACM7000 Family
419
+ ACM5504-5-LR-I,Opengear,Router,2016,2021,ACM5500 family (ACM5504-5-LR-I etc),ACM7000 Family
420
+ ACM7004-2-LA,Opengear,Router,2017,2021,ACM7004-2-Lx family (ACM7004-2-LA/LR/LV etc),ACM7004-2-L
421
+ ACM7004-2-LR,Opengear,Router,2017,2021,ACM7004-2-Lx family (ACM7004-2-LA/LR/LV etc),ACM7004-2-L
422
+ ACM7004-2-LV,Opengear,Router,2017,2021,ACM7004-2-Lx family (ACM7004-2-LA/LR/LV etc),ACM7004-2-L
423
+ CM7100,Opengear,Router,2024,2030,CM7100 family (all SKUs),CM8100 Family
424
+ IM4208-2-,Opengear,Router,2016,2020,"IM4200 family (IM4208-2-...,IM4248-2-...)",IM7200
425
+ IM4248-2-,Opengear,Router,2016,2020,"IM4200 family (IM4208-2-...,IM4248-2-...)",IM7200
426
+ IM7200,Opengear,Router,2024,2030,IM7200 family (all SKUs),CM8100-10G Family