mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
feat(env): add NASA FIRMS satellite fire hotspot detection
- Implement FIRMSAdapter polling NASA FIRMS area API for satellite hotspots - Cross-reference hotspots against NIFC perimeters to identify new ignitions - Add !hotspots command with --new flag for filtering new ignitions only - Add FIRMSConfig dataclass with map_key, source, bbox, day_range options - Add /api/env/hotspots endpoint for dashboard integration - Add Satellite Hotspots section to Environment.tsx with NEW badges - Add FIRMS configuration section to Config.tsx with source/confidence options - Update config.example.yaml with FIRMS configuration template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bb36ebb8c3
commit
3d74eb92b0
13 changed files with 786 additions and 81 deletions
|
|
@ -203,6 +203,19 @@ environmental:
|
||||||
endpoints: ["/get/event"]
|
endpoints: ["/get/event"]
|
||||||
bbox: [] # [west, south, east, north]
|
bbox: [] # [west, south, east, north]
|
||||||
|
|
||||||
|
# NASA FIRMS Satellite Fire Detection
|
||||||
|
# Early warning via satellite hotspots, hours before official perimeters
|
||||||
|
# Get MAP_KEY at: https://firms.modaps.eosdis.nasa.gov/api/area/
|
||||||
|
firms:
|
||||||
|
enabled: false
|
||||||
|
tick_seconds: 1800 # 30 min default
|
||||||
|
map_key: "" # Required - NASA FIRMS MAP_KEY
|
||||||
|
source: "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
|
||||||
|
bbox: [] # [west, south, east, north] - Required
|
||||||
|
day_range: 1 # 1-10 days of data
|
||||||
|
confidence_min: "nominal" # low, nominal, high
|
||||||
|
proximity_km: 10.0 # km to match known fire perimeters
|
||||||
|
|
||||||
# === WEB DASHBOARD ===
|
# === WEB DASHBOARD ===
|
||||||
dashboard:
|
dashboard:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,36 @@ export interface RoadEvent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HotspotEvent {
|
||||||
|
source: string
|
||||||
|
event_id: string
|
||||||
|
event_type: string
|
||||||
|
headline: string
|
||||||
|
severity: string
|
||||||
|
lat?: number
|
||||||
|
lon?: number
|
||||||
|
expires: number
|
||||||
|
fetched_at: number
|
||||||
|
properties: {
|
||||||
|
new_ignition: boolean
|
||||||
|
confidence: string
|
||||||
|
frp?: number
|
||||||
|
brightness?: number
|
||||||
|
acq_date: string
|
||||||
|
acq_time: string
|
||||||
|
near_fire?: string
|
||||||
|
distance_to_fire_km?: number
|
||||||
|
distance_km?: number
|
||||||
|
nearest_anchor?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotspotsResponse {
|
||||||
|
enabled: boolean
|
||||||
|
hotspots: HotspotEvent[]
|
||||||
|
new_ignitions: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AvalancheResponse {
|
export interface AvalancheResponse {
|
||||||
off_season: boolean
|
off_season: boolean
|
||||||
advisories: AvalancheEvent[]
|
advisories: AvalancheEvent[]
|
||||||
|
|
@ -355,6 +385,10 @@ export async function fetchRoads(): Promise<RoadEvent[]> {
|
||||||
return fetchJson<RoadEvent[]>('/api/env/roads')
|
return fetchJson<RoadEvent[]>('/api/env/roads')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchHotspots(): Promise<HotspotsResponse> {
|
||||||
|
return fetchJson<HotspotsResponse>('/api/env/hotspots')
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchRegions(): Promise<RegionInfo[]> {
|
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||||
return fetchJson<RegionInfo[]>('/api/regions')
|
return fetchJson<RegionInfo[]>('/api/regions')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,7 @@ interface EnvironmentalConfig {
|
||||||
usgs: { enabled: boolean; tick_seconds: number; sites: string[] }
|
usgs: { enabled: boolean; tick_seconds: number; sites: string[] }
|
||||||
traffic: { enabled: boolean; tick_seconds: number; api_key: string; corridors: { name: string; lat: number; lon: number }[] }
|
traffic: { enabled: boolean; tick_seconds: number; api_key: string; corridors: { name: string; lat: number; lon: number }[] }
|
||||||
roads511: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[] }
|
roads511: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[] }
|
||||||
|
firms: { enabled: boolean; tick_seconds: number; map_key: string; source: string; bbox: number[]; day_range: number; confidence_min: string; proximity_km: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardConfig {
|
interface DashboardConfig {
|
||||||
|
|
@ -1070,6 +1071,64 @@ function EnvironmentalSection({ data, onChange }: { data: EnvironmentalConfig; o
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-300">NASA FIRMS Satellite Fire Detection</span>
|
||||||
|
<Toggle label="" checked={data.firms?.enabled || false} onChange={(v) => onChange({ ...data, firms: { ...data.firms, enabled: v, tick_seconds: data.firms?.tick_seconds || 1800, map_key: data.firms?.map_key || '', source: data.firms?.source || 'VIIRS_SNPP_NRT', bbox: data.firms?.bbox || [], day_range: data.firms?.day_range || 1, confidence_min: data.firms?.confidence_min || 'nominal', proximity_km: data.firms?.proximity_km || 10 } })} />
|
||||||
|
</div>
|
||||||
|
{data.firms?.enabled && (
|
||||||
|
<>
|
||||||
|
<TextInput label="MAP Key" value={data.firms.map_key} onChange={(v) => onChange({ ...data, firms: { ...data.firms, map_key: v } })} type="password" helper="Get key at firms.modaps.eosdis.nasa.gov/api/area/" />
|
||||||
|
<NumberInput label="Tick Seconds" value={data.firms.tick_seconds} onChange={(v) => onChange({ ...data, firms: { ...data.firms, tick_seconds: v } })} min={300} />
|
||||||
|
<SelectInput
|
||||||
|
label="Satellite Source"
|
||||||
|
value={data.firms.source}
|
||||||
|
onChange={(v) => onChange({ ...data, firms: { ...data.firms, source: v } })}
|
||||||
|
options={[
|
||||||
|
{ value: 'VIIRS_SNPP_NRT', label: 'VIIRS SNPP (Near Real-Time)' },
|
||||||
|
{ value: 'VIIRS_NOAA20_NRT', label: 'VIIRS NOAA-20 (Near Real-Time)' },
|
||||||
|
{ value: 'MODIS_NRT', label: 'MODIS (Near Real-Time)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<NumberInput label="Day Range" value={data.firms.day_range} onChange={(v) => onChange({ ...data, firms: { ...data.firms, day_range: v } })} min={1} max={10} helper="1-10 days of data" />
|
||||||
|
<SelectInput
|
||||||
|
label="Minimum Confidence"
|
||||||
|
value={data.firms.confidence_min}
|
||||||
|
onChange={(v) => onChange({ ...data, firms: { ...data.firms, confidence_min: v } })}
|
||||||
|
options={[
|
||||||
|
{ value: 'low', label: 'Low' },
|
||||||
|
{ value: 'nominal', label: 'Nominal' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<NumberInput label="Proximity (km)" value={data.firms.proximity_km} onChange={(v) => onChange({ ...data, firms: { ...data.firms, proximity_km: v } })} step={0.5} helper="Distance to match known fires" />
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<NumberInput label="West" value={data.firms.bbox?.[0] || 0} onChange={(v) => {
|
||||||
|
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
|
||||||
|
bbox[0] = v
|
||||||
|
onChange({ ...data, firms: { ...data.firms, bbox } })
|
||||||
|
}} step={0.01} />
|
||||||
|
<NumberInput label="South" value={data.firms.bbox?.[1] || 0} onChange={(v) => {
|
||||||
|
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
|
||||||
|
bbox[1] = v
|
||||||
|
onChange({ ...data, firms: { ...data.firms, bbox } })
|
||||||
|
}} step={0.01} />
|
||||||
|
<NumberInput label="East" value={data.firms.bbox?.[2] || 0} onChange={(v) => {
|
||||||
|
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
|
||||||
|
bbox[2] = v
|
||||||
|
onChange({ ...data, firms: { ...data.firms, bbox } })
|
||||||
|
}} step={0.01} />
|
||||||
|
<NumberInput label="North" value={data.firms.bbox?.[3] || 0} onChange={(v) => {
|
||||||
|
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
|
||||||
|
bbox[3] = v
|
||||||
|
onChange({ ...data, firms: { ...data.firms, bbox } })
|
||||||
|
}} step={0.01} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">Bounding box for monitoring area (required)</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Mountain,
|
Mountain,
|
||||||
Droplets,
|
Droplets,
|
||||||
Car,
|
Car,
|
||||||
|
Satellite,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
fetchEnvStatus,
|
fetchEnvStatus,
|
||||||
|
|
@ -23,6 +24,7 @@ import {
|
||||||
fetchStreams,
|
fetchStreams,
|
||||||
fetchTraffic,
|
fetchTraffic,
|
||||||
fetchRoads,
|
fetchRoads,
|
||||||
|
fetchHotspots,
|
||||||
type EnvStatus,
|
type EnvStatus,
|
||||||
type EnvEvent,
|
type EnvEvent,
|
||||||
type SWPCStatus,
|
type SWPCStatus,
|
||||||
|
|
@ -32,6 +34,8 @@ import {
|
||||||
type StreamGaugeEvent,
|
type StreamGaugeEvent,
|
||||||
type TrafficEvent,
|
type TrafficEvent,
|
||||||
type RoadEvent,
|
type RoadEvent,
|
||||||
|
type HotspotEvent,
|
||||||
|
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
|
|
||||||
function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; last_error: string | null; consecutive_errors: number; event_count: number; last_fetch: number } }) {
|
function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; last_error: string | null; consecutive_errors: number; event_count: number; last_fetch: number } }) {
|
||||||
|
|
@ -359,6 +363,8 @@ export default function Environment() {
|
||||||
const [streams, setStreams] = useState<StreamGaugeEvent[]>([])
|
const [streams, setStreams] = useState<StreamGaugeEvent[]>([])
|
||||||
const [traffic, setTraffic] = useState<TrafficEvent[]>([])
|
const [traffic, setTraffic] = useState<TrafficEvent[]>([])
|
||||||
const [roads, setRoads] = useState<RoadEvent[]>([])
|
const [roads, setRoads] = useState<RoadEvent[]>([])
|
||||||
|
const [hotspots, setHotspots] = useState<HotspotEvent[]>([])
|
||||||
|
const [newIgnitions, setNewIgnitions] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -373,8 +379,9 @@ export default function Environment() {
|
||||||
fetchStreams().catch(() => []),
|
fetchStreams().catch(() => []),
|
||||||
fetchTraffic().catch(() => []),
|
fetchTraffic().catch(() => []),
|
||||||
fetchRoads().catch(() => []),
|
fetchRoads().catch(() => []),
|
||||||
|
fetchHotspots().catch(() => ({ hotspots: [], new_ignitions: 0 })),
|
||||||
])
|
])
|
||||||
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData]) => {
|
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData, hotspotsData]) => {
|
||||||
setEnvStatus(status)
|
setEnvStatus(status)
|
||||||
setEvents(active)
|
setEvents(active)
|
||||||
setSWPC(swpcData)
|
setSWPC(swpcData)
|
||||||
|
|
@ -384,6 +391,8 @@ export default function Environment() {
|
||||||
setStreams(streamsData || [])
|
setStreams(streamsData || [])
|
||||||
setTraffic(trafficData || [])
|
setTraffic(trafficData || [])
|
||||||
setRoads(roadsData || [])
|
setRoads(roadsData || [])
|
||||||
|
setHotspots(hotspotsData?.hotspots || [])
|
||||||
|
setNewIgnitions(hotspotsData?.new_ignitions || 0)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
@ -690,6 +699,60 @@ export default function Environment() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Satellite Hotspots */}
|
||||||
|
{hotspots.length > 0 && (
|
||||||
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
|
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||||
|
<Satellite size={14} />
|
||||||
|
Satellite Hotspots ({hotspots.length})
|
||||||
|
{newIgnitions > 0 && (
|
||||||
|
<span className="ml-2 px-2 py-0.5 text-xs rounded-full bg-red-500/20 text-red-400 animate-pulse">
|
||||||
|
{newIgnitions} NEW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hotspots.map((h) => (
|
||||||
|
<div
|
||||||
|
key={h.event_id}
|
||||||
|
className={`p-3 rounded-lg ${
|
||||||
|
h.properties?.new_ignition
|
||||||
|
? 'bg-red-500/10 border-l-2 border-red-500'
|
||||||
|
: h.severity === 'watch'
|
||||||
|
? 'bg-amber-500/10 border-l-2 border-amber-500'
|
||||||
|
: 'bg-orange-500/10 border-l-2 border-orange-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{h.properties?.new_ignition && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
|
||||||
|
NEW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-slate-200">
|
||||||
|
{h.headline}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{h.properties?.frp && (
|
||||||
|
<span className="text-sm font-mono text-orange-400">
|
||||||
|
{Math.round(h.properties.frp)} MW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-1 flex items-center gap-3">
|
||||||
|
<span>Conf: {h.properties?.confidence || 'N/A'}</span>
|
||||||
|
{h.properties?.acq_time && <span>@{h.properties.acq_time}Z</span>}
|
||||||
|
{h.properties?.near_fire && (
|
||||||
|
<span>Near: {h.properties.near_fire}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active Events */}
|
{/* Active Events */}
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,15 @@ def create_dispatcher(
|
||||||
alias_handler.name = alias
|
alias_handler.name = alias
|
||||||
dispatcher.register(alias_handler)
|
dispatcher.register(alias_handler)
|
||||||
|
|
||||||
|
# Register hotspots command (NASA FIRMS satellite fire detection)
|
||||||
|
from .hotspots_cmd import HotspotsCommand
|
||||||
|
hotspots_cmd = HotspotsCommand(env_store)
|
||||||
|
dispatcher.register(hotspots_cmd)
|
||||||
|
for alias in getattr(hotspots_cmd, 'aliases', []):
|
||||||
|
alias_handler = HotspotsCommand(env_store)
|
||||||
|
alias_handler.name = alias
|
||||||
|
dispatcher.register(alias_handler)
|
||||||
|
|
||||||
# Register custom commands
|
# Register custom commands
|
||||||
if custom_commands:
|
if custom_commands:
|
||||||
for name, response in custom_commands.items():
|
for name, response in custom_commands.items():
|
||||||
|
|
|
||||||
100
meshai/commands/hotspots_cmd.py
Normal file
100
meshai/commands/hotspots_cmd.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""Satellite fire hotspot command."""
|
||||||
|
|
||||||
|
from .base import CommandContext, CommandHandler
|
||||||
|
|
||||||
|
|
||||||
|
class HotspotsCommand(CommandHandler):
|
||||||
|
"""Show NASA FIRMS satellite fire hotspot data."""
|
||||||
|
|
||||||
|
aliases = ["satellite", "ignitions"]
|
||||||
|
|
||||||
|
def __init__(self, env_store):
|
||||||
|
self._env_store = env_store
|
||||||
|
self._name = "hotspots"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@name.setter
|
||||||
|
def name(self, value: str):
|
||||||
|
self._name = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return "Show satellite fire hotspots"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usage(self) -> str:
|
||||||
|
return "!hotspots [--new]"
|
||||||
|
|
||||||
|
async def execute(self, args: str, context: CommandContext) -> str:
|
||||||
|
if not self._env_store:
|
||||||
|
return "Environmental feeds not configured."
|
||||||
|
|
||||||
|
# Check for --new flag
|
||||||
|
new_only = "--new" in args.lower() or "new" in args.lower().split()
|
||||||
|
|
||||||
|
# Get FIRMS adapter
|
||||||
|
firms_adapter = getattr(self._env_store, "_firms", None)
|
||||||
|
|
||||||
|
if not firms_adapter:
|
||||||
|
return "Satellite hotspot monitoring not configured."
|
||||||
|
|
||||||
|
if not firms_adapter._is_loaded:
|
||||||
|
return "Satellite data not yet loaded. Try again shortly."
|
||||||
|
|
||||||
|
if firms_adapter._consecutive_errors >= 999:
|
||||||
|
return "Satellite monitoring disabled (invalid API key)."
|
||||||
|
|
||||||
|
# Get events
|
||||||
|
if new_only:
|
||||||
|
events = firms_adapter.get_new_ignitions()
|
||||||
|
title = "NEW IGNITIONS"
|
||||||
|
else:
|
||||||
|
events = firms_adapter.get_events()
|
||||||
|
title = "FIRE HOTSPOTS"
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
if new_only:
|
||||||
|
return "No new ignitions detected. All hotspots near known fires."
|
||||||
|
return "No satellite fire hotspots detected in monitored area."
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
lines = [f"{title} ({len(events)}):"]
|
||||||
|
|
||||||
|
# Sort by severity (warning > watch > advisory) then by FRP
|
||||||
|
severity_order = {"warning": 0, "watch": 1, "advisory": 2}
|
||||||
|
sorted_events = sorted(
|
||||||
|
events,
|
||||||
|
key=lambda e: (
|
||||||
|
severity_order.get(e.get("severity", "advisory"), 3),
|
||||||
|
-(e.get("properties", {}).get("frp") or 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for event in sorted_events[:8]: # Limit for mesh
|
||||||
|
props = event.get("properties", {})
|
||||||
|
severity = event.get("severity", "advisory").upper()[:1] # W/A
|
||||||
|
|
||||||
|
# Format line
|
||||||
|
line = f"[{severity}] {event.get('headline', 'Unknown')}"
|
||||||
|
|
||||||
|
# Add confidence and FRP if available
|
||||||
|
details = []
|
||||||
|
if props.get("confidence"):
|
||||||
|
details.append(f"conf:{props['confidence']}")
|
||||||
|
if props.get("frp"):
|
||||||
|
details.append(f"{int(props['frp'])}MW")
|
||||||
|
if props.get("acq_time"):
|
||||||
|
details.append(f"@{props['acq_time']}Z")
|
||||||
|
|
||||||
|
if details:
|
||||||
|
line += f" ({', '.join(details)})"
|
||||||
|
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
if len(events) > 8:
|
||||||
|
lines.append(f"...and {len(events) - 8} more")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
@ -393,6 +393,20 @@ class Roads511Config:
|
||||||
bbox: list = field(default_factory=list) # [west, south, east, north]
|
bbox: list = field(default_factory=list) # [west, south, east, north]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FIRMSConfig:
|
||||||
|
"""NASA FIRMS satellite fire hotspot settings."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
tick_seconds: int = 1800 # 30 min default
|
||||||
|
map_key: str = "" # NASA FIRMS MAP_KEY, get at https://firms.modaps.eosdis.nasa.gov/api/area/
|
||||||
|
source: str = "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
|
||||||
|
bbox: list = field(default_factory=list) # [west, south, east, north]
|
||||||
|
day_range: int = 1 # 1-10 days of data
|
||||||
|
confidence_min: str = "nominal" # low, nominal, high
|
||||||
|
proximity_km: float = 10.0 # km to match known fire
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EnvironmentalConfig:
|
class EnvironmentalConfig:
|
||||||
"""Environmental feeds settings."""
|
"""Environmental feeds settings."""
|
||||||
|
|
@ -407,6 +421,7 @@ class EnvironmentalConfig:
|
||||||
usgs: USGSConfig = field(default_factory=USGSConfig)
|
usgs: USGSConfig = field(default_factory=USGSConfig)
|
||||||
traffic: TomTomConfig = field(default_factory=TomTomConfig)
|
traffic: TomTomConfig = field(default_factory=TomTomConfig)
|
||||||
roads511: Roads511Config = field(default_factory=Roads511Config)
|
roads511: Roads511Config = field(default_factory=Roads511Config)
|
||||||
|
firms: FIRMSConfig = field(default_factory=FIRMSConfig)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -518,6 +533,8 @@ def _dict_to_dataclass(cls, data: dict):
|
||||||
kwargs[key] = _dict_to_dataclass(TomTomConfig, value)
|
kwargs[key] = _dict_to_dataclass(TomTomConfig, value)
|
||||||
elif key == "roads511" and isinstance(value, dict):
|
elif key == "roads511" and isinstance(value, dict):
|
||||||
kwargs[key] = _dict_to_dataclass(Roads511Config, value)
|
kwargs[key] = _dict_to_dataclass(Roads511Config, value)
|
||||||
|
elif key == "firms" and isinstance(value, dict):
|
||||||
|
kwargs[key] = _dict_to_dataclass(FIRMSConfig, value)
|
||||||
else:
|
else:
|
||||||
kwargs[key] = value
|
kwargs[key] = value
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,3 +138,26 @@ async def get_roads_data(request: Request):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return env_store.get_active(source="511")
|
return env_store.get_active(source="511")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/env/hotspots")
|
||||||
|
async def get_hotspots_data(request: Request):
|
||||||
|
"""Get NASA FIRMS satellite fire hotspots."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
|
return {"hotspots": [], "new_ignitions": 0}
|
||||||
|
|
||||||
|
firms_adapter = getattr(env_store, "_firms", None)
|
||||||
|
|
||||||
|
if not firms_adapter:
|
||||||
|
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
|
||||||
|
|
||||||
|
hotspots = env_store.get_active(source="firms")
|
||||||
|
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"hotspots": hotspots,
|
||||||
|
"new_ignitions": len(new_ignitions),
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-Lqo8lYVT.js"></script>
|
<script type="module" crossorigin src="/assets/index-DyCs3R4y.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DvM_5H7j.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-TnqHKPY8.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
365
meshai/env/firms.py
vendored
Normal file
365
meshai/env/firms.py
vendored
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
"""NASA FIRMS satellite fire hotspot adapter."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..config import FIRMSConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FIRMSAdapter:
|
||||||
|
"""NASA FIRMS satellite fire hotspot polling.
|
||||||
|
|
||||||
|
Detects fire hotspots from satellite data (MODIS, VIIRS) typically
|
||||||
|
hours before NIFC publishes official perimeters. Early warning.
|
||||||
|
|
||||||
|
API: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{MAP_KEY}/{SOURCE}/{BBOX}/{DAY_RANGE}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv"
|
||||||
|
|
||||||
|
def __init__(self, config: "FIRMSConfig", region_anchors: list = None, fires_adapter=None):
|
||||||
|
self._map_key = config.map_key
|
||||||
|
self._source = config.source or "VIIRS_SNPP_NRT"
|
||||||
|
self._bbox = config.bbox # [west, south, east, north]
|
||||||
|
self._day_range = config.day_range or 1
|
||||||
|
self._tick_interval = config.tick_seconds or 1800
|
||||||
|
self._confidence_min = config.confidence_min or "nominal"
|
||||||
|
self._proximity_km = config.proximity_km or 10.0 # km to match known fire
|
||||||
|
|
||||||
|
self._last_tick = 0.0
|
||||||
|
self._events = []
|
||||||
|
self._consecutive_errors = 0
|
||||||
|
self._last_error = None
|
||||||
|
self._is_loaded = False
|
||||||
|
|
||||||
|
# For cross-referencing
|
||||||
|
self._region_anchors = region_anchors or []
|
||||||
|
self._fires_adapter = fires_adapter # NICFFiresAdapter for cross-ref
|
||||||
|
|
||||||
|
def tick(self) -> bool:
|
||||||
|
"""Execute one polling tick.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if data changed
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if now - self._last_tick < self._tick_interval:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._last_tick = now
|
||||||
|
|
||||||
|
if not self._map_key:
|
||||||
|
if not self._last_error:
|
||||||
|
logger.warning("FIRMS: No MAP_KEY configured, skipping")
|
||||||
|
self._last_error = "No MAP_KEY configured"
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._bbox or len(self._bbox) != 4:
|
||||||
|
if not self._last_error:
|
||||||
|
logger.warning("FIRMS: No valid bbox configured, skipping")
|
||||||
|
self._last_error = "No valid bbox configured"
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._fetch()
|
||||||
|
|
||||||
|
def _fetch(self) -> bool:
|
||||||
|
"""Fetch fire hotspots from NASA FIRMS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if data changed
|
||||||
|
"""
|
||||||
|
# Format bbox as west,south,east,north
|
||||||
|
bbox_str = ",".join(str(c) for c in self._bbox)
|
||||||
|
|
||||||
|
url = f"{self.BASE_URL}/{self._map_key}/{self._source}/{bbox_str}/{self._day_range}"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "MeshAI/1.0",
|
||||||
|
"Accept": "text/csv",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = Request(url, headers=headers)
|
||||||
|
with urlopen(req, timeout=30) as resp:
|
||||||
|
csv_data = resp.read().decode("utf-8")
|
||||||
|
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
logger.error("FIRMS: Invalid MAP_KEY, disabling adapter")
|
||||||
|
self._last_error = "Invalid MAP_KEY"
|
||||||
|
self._consecutive_errors = 999 # Disable
|
||||||
|
return False
|
||||||
|
logger.warning(f"FIRMS HTTP error: {e.code}")
|
||||||
|
self._last_error = f"HTTP {e.code}"
|
||||||
|
self._consecutive_errors += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
except URLError as e:
|
||||||
|
logger.warning(f"FIRMS connection error: {e.reason}")
|
||||||
|
self._last_error = str(e.reason)
|
||||||
|
self._consecutive_errors += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"FIRMS fetch error: {e}")
|
||||||
|
self._last_error = str(e)
|
||||||
|
self._consecutive_errors += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Parse CSV response
|
||||||
|
new_events = self._parse_csv(csv_data)
|
||||||
|
|
||||||
|
# Check if data changed
|
||||||
|
old_ids = {e["event_id"] for e in self._events}
|
||||||
|
new_ids = {e["event_id"] for e in new_events}
|
||||||
|
changed = old_ids != new_ids
|
||||||
|
|
||||||
|
self._events = new_events
|
||||||
|
self._consecutive_errors = 0
|
||||||
|
self._last_error = None
|
||||||
|
self._is_loaded = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
new_ignitions = sum(1 for e in new_events if e.get("properties", {}).get("new_ignition"))
|
||||||
|
logger.info(f"FIRMS hotspots updated: {len(new_events)} total, {new_ignitions} potential new ignitions")
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def _parse_csv(self, csv_data: str) -> list:
|
||||||
|
"""Parse FIRMS CSV response into events."""
|
||||||
|
lines = csv_data.strip().split("\n")
|
||||||
|
if len(lines) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Parse header
|
||||||
|
header = lines[0].split(",")
|
||||||
|
header_map = {col.strip().lower(): i for i, col in enumerate(header)}
|
||||||
|
|
||||||
|
# Required columns
|
||||||
|
lat_idx = header_map.get("latitude")
|
||||||
|
lon_idx = header_map.get("longitude")
|
||||||
|
conf_idx = header_map.get("confidence")
|
||||||
|
frp_idx = header_map.get("frp") # Fire Radiative Power
|
||||||
|
acq_date_idx = header_map.get("acq_date")
|
||||||
|
acq_time_idx = header_map.get("acq_time")
|
||||||
|
bright_idx = header_map.get("bright_ti4") or header_map.get("brightness")
|
||||||
|
|
||||||
|
if lat_idx is None or lon_idx is None:
|
||||||
|
logger.warning("FIRMS CSV missing required columns")
|
||||||
|
return []
|
||||||
|
|
||||||
|
events = []
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Confidence mapping
|
||||||
|
conf_values = {"low": 1, "l": 1, "nominal": 2, "n": 2, "high": 3, "h": 3}
|
||||||
|
min_conf = conf_values.get(self._confidence_min.lower(), 2)
|
||||||
|
|
||||||
|
# Get known fire locations for cross-referencing
|
||||||
|
known_fires = self._get_known_fires()
|
||||||
|
|
||||||
|
for line in lines[1:]:
|
||||||
|
cols = line.split(",")
|
||||||
|
if len(cols) < max(filter(None, [lat_idx, lon_idx, conf_idx])) + 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = float(cols[lat_idx])
|
||||||
|
lon = float(cols[lon_idx])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse confidence
|
||||||
|
conf_raw = cols[conf_idx].strip() if conf_idx is not None and conf_idx < len(cols) else "n"
|
||||||
|
conf_value = conf_values.get(conf_raw.lower(), 2)
|
||||||
|
|
||||||
|
# Filter by confidence
|
||||||
|
if conf_value < min_conf:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse FRP (fire radiative power in MW)
|
||||||
|
frp = None
|
||||||
|
if frp_idx is not None and frp_idx < len(cols):
|
||||||
|
try:
|
||||||
|
frp = float(cols[frp_idx])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Parse brightness temperature
|
||||||
|
brightness = None
|
||||||
|
if bright_idx is not None and bright_idx < len(cols):
|
||||||
|
try:
|
||||||
|
brightness = float(cols[bright_idx])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Parse acquisition datetime
|
||||||
|
acq_date = cols[acq_date_idx].strip() if acq_date_idx is not None and acq_date_idx < len(cols) else ""
|
||||||
|
acq_time = cols[acq_time_idx].strip() if acq_time_idx is not None and acq_time_idx < len(cols) else ""
|
||||||
|
|
||||||
|
# Create unique ID from position and time
|
||||||
|
event_id = f"firms_{lat:.4f}_{lon:.4f}_{acq_date}_{acq_time}"
|
||||||
|
|
||||||
|
# Check if near known fire
|
||||||
|
near_fire, fire_name, distance_to_fire = self._check_near_known_fire(lat, lon, known_fires)
|
||||||
|
|
||||||
|
# Determine severity
|
||||||
|
if not near_fire:
|
||||||
|
# Potential new ignition
|
||||||
|
severity = "watch"
|
||||||
|
new_ignition = True
|
||||||
|
headline = f"NEW HOTSPOT detected"
|
||||||
|
else:
|
||||||
|
# Near known fire
|
||||||
|
severity = "advisory"
|
||||||
|
new_ignition = False
|
||||||
|
headline = f"Hotspot near {fire_name}"
|
||||||
|
|
||||||
|
# Bump severity for high FRP
|
||||||
|
if frp is not None and frp > 100:
|
||||||
|
if severity == "advisory":
|
||||||
|
severity = "watch"
|
||||||
|
elif severity == "watch":
|
||||||
|
severity = "warning"
|
||||||
|
headline += f" ({int(frp)} MW)"
|
||||||
|
|
||||||
|
# Compute proximity to region anchors
|
||||||
|
distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon)
|
||||||
|
|
||||||
|
if distance_km is not None and nearest_anchor:
|
||||||
|
headline += f" ({int(distance_km)} km from {nearest_anchor})"
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"source": "firms",
|
||||||
|
"event_id": event_id,
|
||||||
|
"event_type": "Fire Hotspot",
|
||||||
|
"severity": severity,
|
||||||
|
"headline": headline,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"expires": now + 21600, # 6 hour TTL
|
||||||
|
"fetched_at": now,
|
||||||
|
"properties": {
|
||||||
|
"new_ignition": new_ignition,
|
||||||
|
"confidence": conf_raw,
|
||||||
|
"frp": frp,
|
||||||
|
"brightness": brightness,
|
||||||
|
"acq_date": acq_date,
|
||||||
|
"acq_time": acq_time,
|
||||||
|
"near_fire": fire_name if near_fire else None,
|
||||||
|
"distance_to_fire_km": distance_to_fire,
|
||||||
|
"distance_km": distance_km,
|
||||||
|
"nearest_anchor": nearest_anchor,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _get_known_fires(self) -> list:
|
||||||
|
"""Get known fire locations from NIFC adapter."""
|
||||||
|
if not self._fires_adapter:
|
||||||
|
return []
|
||||||
|
|
||||||
|
fires = self._fires_adapter.get_events()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": f.get("name", "Unknown"),
|
||||||
|
"lat": f.get("lat"),
|
||||||
|
"lon": f.get("lon"),
|
||||||
|
}
|
||||||
|
for f in fires
|
||||||
|
if f.get("lat") is not None and f.get("lon") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
def _check_near_known_fire(self, lat: float, lon: float, known_fires: list) -> tuple:
|
||||||
|
"""Check if hotspot is near a known fire.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_near, fire_name, distance_km)
|
||||||
|
"""
|
||||||
|
if not known_fires:
|
||||||
|
return (False, None, None)
|
||||||
|
|
||||||
|
from ..geo import haversine_distance
|
||||||
|
|
||||||
|
for fire in known_fires:
|
||||||
|
fire_lat = fire.get("lat")
|
||||||
|
fire_lon = fire.get("lon")
|
||||||
|
if fire_lat is None or fire_lon is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# haversine_distance returns miles, convert to km
|
||||||
|
dist_miles = haversine_distance(lat, lon, fire_lat, fire_lon)
|
||||||
|
dist_km = dist_miles * 1.60934
|
||||||
|
|
||||||
|
if dist_km <= self._proximity_km:
|
||||||
|
return (True, fire.get("name"), dist_km)
|
||||||
|
|
||||||
|
return (False, None, None)
|
||||||
|
|
||||||
|
def _nearest_anchor_distance(self, lat: float, lon: float) -> tuple:
|
||||||
|
"""Find distance to nearest region anchor.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(distance_km, anchor_name) or (None, None)
|
||||||
|
"""
|
||||||
|
if not self._region_anchors:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
from ..geo import haversine_distance
|
||||||
|
|
||||||
|
min_dist = float("inf")
|
||||||
|
nearest_name = None
|
||||||
|
|
||||||
|
for anchor in self._region_anchors:
|
||||||
|
anchor_lat = anchor.get("lat") if isinstance(anchor, dict) else getattr(anchor, "lat", None)
|
||||||
|
anchor_lon = anchor.get("lon") if isinstance(anchor, dict) else getattr(anchor, "lon", None)
|
||||||
|
anchor_name = anchor.get("name") if isinstance(anchor, dict) else getattr(anchor, "name", "Unknown")
|
||||||
|
|
||||||
|
if anchor_lat is None or anchor_lon is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# haversine_distance returns miles, convert to km
|
||||||
|
dist_miles = haversine_distance(lat, lon, anchor_lat, anchor_lon)
|
||||||
|
dist_km = dist_miles * 1.60934
|
||||||
|
|
||||||
|
if dist_km < min_dist:
|
||||||
|
min_dist = dist_km
|
||||||
|
nearest_name = anchor_name
|
||||||
|
|
||||||
|
if min_dist < float("inf"):
|
||||||
|
return (min_dist, nearest_name)
|
||||||
|
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
def get_events(self) -> list:
|
||||||
|
"""Get current hotspot events."""
|
||||||
|
return self._events
|
||||||
|
|
||||||
|
def get_new_ignitions(self) -> list:
|
||||||
|
"""Get only potential new ignitions (not near known fires)."""
|
||||||
|
return [e for e in self._events if e.get("properties", {}).get("new_ignition")]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def health_status(self) -> dict:
|
||||||
|
"""Get adapter health status."""
|
||||||
|
new_ignitions = len(self.get_new_ignitions())
|
||||||
|
return {
|
||||||
|
"source": "firms",
|
||||||
|
"is_loaded": self._is_loaded,
|
||||||
|
"last_error": str(self._last_error) if self._last_error else None,
|
||||||
|
"consecutive_errors": self._consecutive_errors,
|
||||||
|
"event_count": len(self._events),
|
||||||
|
"new_ignitions": new_ignitions,
|
||||||
|
"last_fetch": self._last_tick,
|
||||||
|
}
|
||||||
17
meshai/env/store.py
vendored
17
meshai/env/store.py
vendored
|
|
@ -54,6 +54,13 @@ class EnvironmentalStore:
|
||||||
from .roads511 import Roads511Adapter
|
from .roads511 import Roads511Adapter
|
||||||
self._adapters["roads511"] = Roads511Adapter(config.roads511)
|
self._adapters["roads511"] = Roads511Adapter(config.roads511)
|
||||||
|
|
||||||
|
# FIRMS needs reference to NIFC adapter for cross-referencing
|
||||||
|
if config.firms.enabled:
|
||||||
|
from .firms import FIRMSAdapter
|
||||||
|
fires_adapter = self._adapters.get("nifc")
|
||||||
|
self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter)
|
||||||
|
self._adapters["firms"] = self._firms
|
||||||
|
|
||||||
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
||||||
|
|
||||||
def refresh(self) -> bool:
|
def refresh(self) -> bool:
|
||||||
|
|
@ -224,6 +231,16 @@ class EnvironmentalStore:
|
||||||
for r in roads[:2]:
|
for r in roads[:2]:
|
||||||
lines.append(f" - {r['headline'][:60]}")
|
lines.append(f" - {r['headline'][:60]}")
|
||||||
|
|
||||||
|
# Satellite hotspots
|
||||||
|
hotspots = self.get_active(source="firms")
|
||||||
|
if hotspots:
|
||||||
|
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
||||||
|
lines.append(f"Satellite Hotspots: {len(hotspots)} detected")
|
||||||
|
if new_ignitions:
|
||||||
|
lines.append(f" *** {len(new_ignitions)} POTENTIAL NEW IGNITION(S) ***")
|
||||||
|
for h in hotspots[:2]:
|
||||||
|
lines.append(f" - {h['headline']}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def get_source_health(self) -> list:
|
def get_source_health(self) -> list:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue