@ -1,8 +1,8 @@
from __future__ import annotations
from __future__ import annotations
import argparse
import argparse
import copy
import hmac
import hmac
import itertools
import json
import json
import math
import math
import mimetypes
import mimetypes
@ -27,6 +27,10 @@ MAX_CONFIG_BODY_BYTES = 1_000_000 # 1 MB guardrail for /config POST.
HZ_IN_MHZ = 1_000_000.0
HZ_IN_MHZ = 1_000_000.0
def _utc_now_iso_seconds ( ) - > str :
return datetime . now ( timezone . utc ) . isoformat ( timespec = " seconds " )
def _load_json ( path : str ) - > Dict [ str , object ] :
def _load_json ( path : str ) - > Dict [ str , object ] :
file_path = Path ( path )
file_path = Path ( path )
if not file_path . exists ( ) :
if not file_path . exists ( ) :
@ -114,20 +118,23 @@ def _parse_frequency_hz_from_measurement(
keys = (
keys = (
" frequency_hz " ,
" frequency_hz " ,
" freq_hz " ,
" freq_hz " ,
" f_hz " ,
" frequency_mhz " ,
" frequency_mhz " ,
" freq_mhz " ,
" freq_mhz " ,
" f_mhz " ,
" frequency " ,
" frequency " ,
" freq " ,
" freq " ,
" f " ,
) ,
) ,
field_name = " frequency " ,
field_name = " frequency " ,
source_label = source_label ,
source_label = source_label ,
row_index = row_index ,
row_index = row_index ,
)
)
if key in ( " frequency_hz " , " freq_hz " ):
if key in ( " frequency_hz " , " freq_hz " , " f_hz " ):
return value
return value
if key in ( " frequency_mhz " , " freq_mhz " ):
if key in ( " frequency_mhz " , " freq_mhz " , " f_mhz " ):
return value * HZ_IN_MHZ
return value * HZ_IN_MHZ
# For generic fields "frequency"/"freq" default to MHz in this project.
# For generic fields default to MHz in this project.
# Keep backward compatibility: very large values are treated as Hz.
# Keep backward compatibility: very large values are treated as Hz.
if value > = 10_000_000.0 :
if value > = 10_000_000.0 :
return value
return value
@ -135,11 +142,21 @@ def _parse_frequency_hz_from_measurement(
def _parse_receiver_input_filter (
def _parse_receiver_input_filter (
receiver_obj : Dict [ str , object ] , receiver_id : str
receiver_obj : Dict [ str , object ] ,
receiver_id : str ,
default_filter_obj : Optional [ Dict [ str , object ] ] = None ,
) - > Dict [ str , object ] :
) - > Dict [ str , object ] :
filter_obj = receiver_obj . get ( " input_filter " , { } )
raw_receiver_filter = receiver_obj . get ( " input_filter " )
if filter_obj is None :
if raw_receiver_filter is None :
filter_obj = { }
raw_receiver_filter = { }
if not isinstance ( raw_receiver_filter , dict ) :
raise ValueError ( f " receiver ' { receiver_id } ' : input_filter must be an object. " )
merged_filter : Dict [ str , object ] = { }
if isinstance ( default_filter_obj , dict ) :
merged_filter . update ( default_filter_obj )
merged_filter . update ( raw_receiver_filter )
filter_obj = merged_filter
if not isinstance ( filter_obj , dict ) :
if not isinstance ( filter_obj , dict ) :
raise ValueError ( f " receiver ' { receiver_id } ' : input_filter must be an object. " )
raise ValueError ( f " receiver ' { receiver_id } ' : input_filter must be an object. " )
@ -191,6 +208,110 @@ def _apply_receiver_input_filter(
return filtered
return filtered
def _parse_receiver_configured_frequencies (
receiver_obj : Dict [ str , object ] ,
receiver_id : str ,
) - > List [ int ] :
raw_frequencies = receiver_obj . get ( " frequencies_mhz " )
if raw_frequencies is None :
return [ ]
if not isinstance ( raw_frequencies , list ) :
raise ValueError (
f " receiver ' { receiver_id } ' : frequencies_mhz must be an array of numbers. "
)
parsed_hz : List [ int ] = [ ]
for index , value in enumerate ( raw_frequencies , start = 1 ) :
try :
frequency_mhz = float ( value )
except ( TypeError , ValueError ) :
raise ValueError (
f " receiver ' { receiver_id } ' : frequencies_mhz[ { index } ] must be numeric. "
) from None
if not math . isfinite ( frequency_mhz ) or frequency_mhz < = 0.0 :
raise ValueError (
f " receiver ' { receiver_id } ' : frequencies_mhz[ { index } ] must be > 0. "
)
parsed_hz . append ( int ( round ( frequency_mhz * HZ_IN_MHZ ) ) )
return sorted ( set ( parsed_hz ) )
def _apply_receiver_configured_frequencies (
measurements : Sequence [ Tuple [ float , float ] ] ,
configured_frequencies_hz : Sequence [ int ] ,
) - > List [ Tuple [ float , float ] ] :
if not configured_frequencies_hz :
return list ( measurements )
allowed = set ( int ( value ) for value in configured_frequencies_hz )
filtered : List [ Tuple [ float , float ] ] = [ ]
for frequency_hz , rssi_dbm in measurements :
rounded_hz = int ( round ( frequency_hz ) )
if rounded_hz in allowed :
filtered . append ( ( float ( rounded_hz ) , rssi_dbm ) )
return filtered
def _parse_output_server_config (
output_obj : Dict [ str , object ] ,
default_name : str ,
) - > Dict [ str , object ] :
name = str ( output_obj . get ( " name " , default_name ) ) . strip ( ) or default_name
ip = str ( output_obj . get ( " ip " , " " ) ) . strip ( )
# Keep backward compatibility for explicit enabled flag, but allow simplified config:
# if enabled is omitted, non-empty IP means enabled output target.
if " enabled " in output_obj :
enabled = bool ( output_obj . get ( " enabled " ) )
else :
enabled = bool ( ip )
port = int ( output_obj . get ( " port " , 8080 ) )
path = str ( output_obj . get ( " path " , " /triangulation " ) )
timeout_s = float ( output_obj . get ( " timeout_s " , 3.0 ) )
frequency_filter_enabled = bool ( output_obj . get ( " frequency_filter_enabled " , False ) )
min_frequency_mhz_raw = output_obj . get ( " min_frequency_mhz " )
max_frequency_mhz_raw = output_obj . get ( " max_frequency_mhz " )
if min_frequency_mhz_raw is None and " min_frequency_hz " in output_obj :
min_frequency_mhz_raw = float ( output_obj [ " min_frequency_hz " ] ) / HZ_IN_MHZ
if max_frequency_mhz_raw is None and " max_frequency_hz " in output_obj :
max_frequency_mhz_raw = float ( output_obj [ " max_frequency_hz " ] ) / HZ_IN_MHZ
min_frequency_mhz = float ( min_frequency_mhz_raw or 0.0 )
max_frequency_mhz = float ( max_frequency_mhz_raw or 0.0 )
min_frequency_hz = min_frequency_mhz * HZ_IN_MHZ
max_frequency_hz = max_frequency_mhz * HZ_IN_MHZ
if enabled and not ip :
raise ValueError ( f " runtime output ' { name } ' : ip must be non-empty when enabled=true. " )
if frequency_filter_enabled :
if min_frequency_mhz < = 0.0 :
raise ValueError (
f " runtime output ' { name } ' : min_frequency_mhz must be > 0 when frequency filter is enabled. "
)
if max_frequency_mhz < = 0.0 :
raise ValueError (
f " runtime output ' { name } ' : max_frequency_mhz must be > 0 when frequency filter is enabled. "
)
if max_frequency_mhz < min_frequency_mhz :
raise ValueError (
f " runtime output ' { name } ' : max_frequency_mhz must be >= min_frequency_mhz. "
)
return {
" name " : name ,
" enabled " : enabled ,
" ip " : ip ,
" port " : port ,
" path " : path ,
" timeout_s " : timeout_s ,
" frequency_filter_enabled " : frequency_filter_enabled ,
" min_frequency_mhz " : min_frequency_mhz ,
" max_frequency_mhz " : max_frequency_mhz ,
" min_frequency_hz " : min_frequency_hz ,
" max_frequency_hz " : max_frequency_hz ,
}
def parse_source_payload (
def parse_source_payload (
payload : object ,
payload : object ,
source_label : str ,
source_label : str ,
@ -209,6 +330,8 @@ def parse_source_payload(
raw_items = payload . get ( " samples " )
raw_items = payload . get ( " samples " )
if raw_items is None :
if raw_items is None :
raw_items = payload . get ( " data " )
raw_items = payload . get ( " data " )
if raw_items is None :
raw_items = payload . get ( " m " )
elif isinstance ( payload , list ) :
elif isinstance ( payload , list ) :
raw_items = payload
raw_items = payload
else :
else :
@ -228,7 +351,7 @@ def parse_source_payload(
)
)
amplitude_dbm = _float_from_measurement (
amplitude_dbm = _float_from_measurement (
row ,
row ,
keys = ( " amplitude_dbm " , " rssi_dbm " , " amplitude" , " rssi " ) ,
keys = ( " amplitude_dbm " , " rssi_dbm " , " dbm" , " amplitude" , " rssi " ) ,
field_name = " amplitude_dbm " ,
field_name = " amplitude_dbm " ,
source_label = source_label ,
source_label = source_label ,
row_index = row_index ,
row_index = row_index ,
@ -272,9 +395,13 @@ def _fetch_measurements(
url : str ,
url : str ,
timeout_s : float ,
timeout_s : float ,
expected_receiver_id : Optional [ str ] = None ,
expected_receiver_id : Optional [ str ] = None ,
headers : Optional [ Dict [ str , str ] ] = None ,
) - > List [ Tuple [ float , float ] ] :
) - > List [ Tuple [ float , float ] ] :
source_label = f " source_url= { url } "
source_label = f " source_url= { url } "
req = request . Request ( url = url , method = " GET " , headers = { " Accept " : " application/json " } )
request_headers = { " Accept " : " application/json " }
if headers :
request_headers . update ( headers )
req = request . Request ( url = url , method = " GET " , headers = request_headers )
try :
try :
with request . urlopen ( req , timeout = timeout_s ) as response :
with request . urlopen ( req , timeout = timeout_s ) as response :
payload = json . loads ( response . read ( ) . decode ( " utf-8 " ) )
payload = json . loads ( response . read ( ) . decode ( " utf-8 " ) )
@ -320,46 +447,36 @@ class AutoService:
self . poll_interval_s = float ( runtime_obj . get ( " poll_interval_s " , 1.0 ) )
self . poll_interval_s = float ( runtime_obj . get ( " poll_interval_s " , 1.0 ) )
self . write_api_token = str ( runtime_obj . get ( " write_api_token " , " " ) ) . strip ( )
self . write_api_token = str ( runtime_obj . get ( " write_api_token " , " " ) ) . strip ( )
parsed_output_servers : List [ Dict [ str , object ] ] = [ ]
output_servers_obj = runtime_obj . get ( " output_servers " )
if output_servers_obj is not None :
if not isinstance ( output_servers_obj , list ) :
raise ValueError ( " runtime.output_servers must be list. " )
for index , output_obj in enumerate ( output_servers_obj , start = 1 ) :
if not isinstance ( output_obj , dict ) :
raise ValueError ( " runtime.output_servers[] must be object. " )
parsed_output_servers . append (
_parse_output_server_config (
output_obj = output_obj ,
default_name = f " output_ { index } " ,
)
)
else :
output_obj = runtime_obj . get ( " output_server " , { } )
output_obj = runtime_obj . get ( " output_server " , { } )
if output_obj is None :
if output_obj is None :
output_obj = { }
output_obj = { }
if not isinstance ( output_obj , dict ) :
if not isinstance ( output_obj , dict ) :
raise ValueError ( " runtime.output_server must be object. " )
raise ValueError ( " runtime.output_server must be object. " )
parsed_output_servers . append (
self . output_enabled = bool ( output_obj . get ( " enabled " , False ) )
_parse_output_server_config (
self . output_ip = str ( output_obj . get ( " ip " , " " ) )
output_obj = output_obj ,
self . output_port = int ( output_obj . get ( " port " , 8080 ) )
default_name = " output_1 " ,
self . output_path = str ( output_obj . get ( " path " , " /triangulation " ) )
self . output_timeout_s = float ( output_obj . get ( " timeout_s " , 3.0 ) )
self . output_frequency_filter_enabled = bool (
output_obj . get ( " frequency_filter_enabled " , False )
)
)
min_frequency_mhz_raw = output_obj . get ( " min_frequency_mhz " )
max_frequency_mhz_raw = output_obj . get ( " max_frequency_mhz " )
if min_frequency_mhz_raw is None and " min_frequency_hz " in output_obj :
min_frequency_mhz_raw = float ( output_obj [ " min_frequency_hz " ] ) / HZ_IN_MHZ
if max_frequency_mhz_raw is None and " max_frequency_hz " in output_obj :
max_frequency_mhz_raw = float ( output_obj [ " max_frequency_hz " ] ) / HZ_IN_MHZ
self . output_min_frequency_mhz = float ( min_frequency_mhz_raw or 0.0 )
self . output_max_frequency_mhz = float ( max_frequency_mhz_raw or 0.0 )
self . output_min_frequency_hz = self . output_min_frequency_mhz * HZ_IN_MHZ
self . output_max_frequency_hz = self . output_max_frequency_mhz * HZ_IN_MHZ
if self . output_enabled and not self . output_ip :
raise ValueError ( " runtime.output_server.ip must be non-empty when enabled=true. " )
if self . output_frequency_filter_enabled :
if self . output_min_frequency_mhz < = 0.0 :
raise ValueError (
" runtime.output_server.min_frequency_mhz must be > 0 when frequency filter is enabled. "
)
if self . output_max_frequency_mhz < = 0.0 :
raise ValueError (
" runtime.output_server.max_frequency_mhz must be > 0 when frequency filter is enabled. "
)
if self . output_max_frequency_mhz < self . output_min_frequency_mhz :
raise ValueError (
" runtime.output_server.max_frequency_mhz must be >= min_frequency_mhz. "
)
)
self . output_servers = parsed_output_servers
self . output_enabled = any ( bool ( server . get ( " enabled " ) ) for server in self . output_servers )
self . source_timeout_s = float ( input_obj . get ( " source_timeout_s " , 3.0 ) )
self . source_timeout_s = float ( input_obj . get ( " source_timeout_s " , 3.0 ) )
self . aggregation = str ( input_obj . get ( " aggregation " , " median " ) )
self . aggregation = str ( input_obj . get ( " aggregation " , " median " ) )
if self . aggregation not in ( " median " , " mean " ) :
if self . aggregation not in ( " median " , " mean " ) :
@ -369,22 +486,59 @@ class AutoService:
if input_mode != " http_sources " :
if input_mode != " http_sources " :
raise ValueError ( " Automatic service requires input.mode = ' http_sources ' . " )
raise ValueError ( " Automatic service requires input.mode = ' http_sources ' . " )
raw_default_filter = input_obj . get ( " default_input_filter " )
default_filter_obj : Optional [ Dict [ str , object ] ] = None
if raw_default_filter is not None :
if not isinstance ( raw_default_filter , dict ) :
raise ValueError ( " input.default_input_filter must be object. " )
default_filter_obj = raw_default_filter
receivers = input_obj . get ( " receivers " )
receivers = input_obj . get ( " receivers " )
if not isinstance ( receivers , list ) or len ( receivers ) != 3 :
if not isinstance ( receivers , list ) or len ( receivers ) < 3 :
raise ValueError ( " input.receivers must contain exactly 3 objects. " )
raise ValueError ( " input.receivers must contain at least 3 objects." )
parsed_receivers : List [ Dict [ str , object ] ] = [ ]
parsed_receivers : List [ Dict [ str , object ] ] = [ ]
for receiver in receivers :
for receiver in receivers :
if not isinstance ( receiver , dict ) :
if not isinstance ( receiver , dict ) :
raise ValueError ( " Each receiver must be object. " )
raise ValueError ( " Each receiver must be object. " )
access_obj = receiver . get ( " access " , { } )
if access_obj is None :
access_obj = { }
if not isinstance ( access_obj , dict ) :
raise ValueError ( " receiver.access must be object. " )
source_url = str (
receiver . get ( " source_url " )
or access_obj . get ( " url " )
or access_obj . get ( " source_url " )
or " "
) . strip ( )
if not source_url :
raise ValueError (
f " receiver ' { receiver . get ( ' receiver_id ' , ' ' ) } ' : source_url/access.url must be non-empty. "
)
source_headers : Dict [ str , str ] = { }
source_api_token = str (
receiver . get ( " source_api_token " ) or access_obj . get ( " api_token " ) or " "
) . strip ( )
if source_api_token :
source_headers [ " Authorization " ] = f " Bearer { source_api_token } "
parsed_receivers . append (
parsed_receivers . append (
{
{
" receiver_id " : str ( receiver [ " receiver_id " ] ) ,
" receiver_id " : str ( receiver [ " receiver_id " ] ) ,
" center " : _center_from_obj ( receiver ) ,
" center " : _center_from_obj ( receiver ) ,
" source_url " : str ( receiver [ " source_url " ] ) ,
" source_url " : source_url ,
" source_headers " : source_headers ,
" configured_frequencies_hz " : _parse_receiver_configured_frequencies (
receiver_obj = receiver ,
receiver_id = str ( receiver [ " receiver_id " ] ) ,
) ,
" input_filter " : _parse_receiver_input_filter (
" input_filter " : _parse_receiver_input_filter (
receiver_obj = receiver ,
receiver_obj = receiver ,
receiver_id = str ( receiver [ " receiver_id " ] ) ,
receiver_id = str ( receiver [ " receiver_id " ] ) ,
default_filter_obj = default_filter_obj ,
) ,
) ,
}
}
)
)
@ -400,6 +554,27 @@ class AutoService:
" http_status " : None ,
" http_status " : None ,
" response_body " : " " ,
" response_body " : " " ,
" sent_at_utc " : None ,
" sent_at_utc " : None ,
" servers " : [
{
" name " : server [ " name " ] ,
" enabled " : bool ( server [ " enabled " ] ) ,
" status " : " disabled " if not bool ( server [ " enabled " ] ) else " pending " ,
" http_status " : None ,
" response_body " : " " ,
" sent_at_utc " : None ,
" target " : {
" ip " : server [ " ip " ] ,
" port " : server [ " port " ] ,
" path " : server [ " path " ] ,
} ,
" frequency_filter " : {
" enabled " : server [ " frequency_filter_enabled " ] ,
" min_frequency_mhz " : server [ " min_frequency_mhz " ] ,
" max_frequency_mhz " : server [ " max_frequency_mhz " ] ,
} ,
}
for server in self . output_servers
] ,
}
}
self . stop_event = threading . Event ( )
self . stop_event = threading . Event ( )
@ -410,6 +585,7 @@ class AutoService:
def stop ( self ) - > None :
def stop ( self ) - > None :
self . stop_event . set ( )
self . stop_event . set ( )
if self . poll_thread . is_alive ( ) :
self . poll_thread . join ( timeout = 2.0 )
self . poll_thread . join ( timeout = 2.0 )
def refresh_once ( self ) - > None :
def refresh_once ( self ) - > None :
@ -420,18 +596,26 @@ class AutoService:
receiver_id = str ( receiver [ " receiver_id " ] )
receiver_id = str ( receiver [ " receiver_id " ] )
center = receiver [ " center " ]
center = receiver [ " center " ]
source_url = str ( receiver [ " source_url " ] )
source_url = str ( receiver [ " source_url " ] )
source_headers = receiver . get ( " source_headers " )
raw_measurements = _fetch_measurements (
raw_measurements = _fetch_measurements (
source_url ,
source_url ,
timeout_s = self . source_timeout_s ,
timeout_s = self . source_timeout_s ,
expected_receiver_id = receiver_id ,
expected_receiver_id = receiver_id ,
headers = source_headers if isinstance ( source_headers , dict ) else None ,
)
)
receiver_filter = receiver [ " input_filter " ]
receiver_filter = receiver [ " input_filter " ]
measurements = _apply_receiver_input_filter (
measurements = _apply_receiver_input_filter (
raw_measurements , receiver_filter = receiver_filter
raw_measurements , receiver_filter = receiver_filter
)
)
configured_frequencies_hz = receiver . get ( " configured_frequencies_hz " , [ ] )
if isinstance ( configured_frequencies_hz , list ) :
measurements = _apply_receiver_configured_frequencies (
measurements ,
configured_frequencies_hz = configured_frequencies_hz ,
)
if not measurements :
if not measurements :
raise RuntimeError (
raise RuntimeError (
f " receiver ' { receiver_id } ' : no measurements left after input_filter. "
f " receiver ' { receiver_id } ' : no measurements left after configured filters ."
)
)
grouped = _group_by_frequency ( measurements )
grouped = _group_by_frequency ( measurements )
grouped_by_receiver . append ( grouped )
grouped_by_receiver . append ( grouped )
@ -460,6 +644,14 @@ class AutoService:
" source_url " : source_url ,
" source_url " : source_url ,
" aggregation " : self . aggregation ,
" aggregation " : self . aggregation ,
" input_filter " : receiver_filter ,
" input_filter " : receiver_filter ,
" configured_frequencies_mhz " : [
float ( int ( value ) ) / HZ_IN_MHZ
for value in (
configured_frequencies_hz
if isinstance ( configured_frequencies_hz , list )
else [ ]
)
] ,
" raw_samples_count " : len ( raw_measurements ) ,
" raw_samples_count " : len ( raw_measurements ) ,
" filtered_samples_count " : len ( measurements ) ,
" filtered_samples_count " : len ( measurements ) ,
" radius_m_all_freq " : radius_m ,
" radius_m_all_freq " : radius_m ,
@ -467,28 +659,36 @@ class AutoService:
}
}
)
)
# Only compare homogeneous measurements: same frequency across all receivers.
common_frequencies = (
set ( grouped_by_receiver [ 0 ] . keys ( ) )
& set ( grouped_by_receiver [ 1 ] . keys ( ) )
& set ( grouped_by_receiver [ 2 ] . keys ( ) )
)
if not common_frequencies :
raise RuntimeError ( " No common frequencies across all 3 receivers. " )
frequency_rows : List [ Dict [ str , object ] ] = [ ]
frequency_rows : List [ Dict [ str , object ] ] = [ ]
best_row : Optional [ Dict [ str , object ] ] = None
best_row : Optional [ Dict [ str , object ] ] = None
for frequency_hz in sorted ( common_frequencies ) :
all_frequencies = sorted (
{ frequency for grouped in grouped_by_receiver for frequency in grouped . keys ( ) }
)
for frequency_hz in all_frequencies :
available_indices = [
idx for idx , grouped in enumerate ( grouped_by_receiver ) if frequency_hz in grouped
]
if len ( available_indices ) < 3 :
continue
best_combo_row : Optional [ Dict [ str , object ] ] = None
best_combo_result = None
best_combo_indices : Optional [ Tuple [ int , int , int ] ] = None
best_combo_spheres : Optional [ List [ Sphere ] ] = None
for combo in itertools . combinations ( available_indices , 3 ) :
spheres_for_frequency : List [ Sphere ] = [ ]
spheres_for_frequency : List [ Sphere ] = [ ]
row_receivers : List [ Dict [ str , object ] ] = [ ]
row_receivers : List [ Dict [ str , object ] ] = [ ]
for index , receiver in enumerate ( self . receivers ) :
for receiver_index in combo :
center = receiver [ " center " ]
receiver = self . receivers [ receiver_index ]
measurement_subset = grouped_by_receiver [ index ] [ frequency_hz ]
measurement_subset = grouped_by_receiver [ receiver_ index] [ frequency_hz ]
radius_m = aggregate_radius (
radius_m = aggregate_radius (
measurement_subset , model = self . model , method = self . aggregation
measurement_subset , model = self . model , method = self . aggregation
)
)
spheres_for_frequency . append ( Sphere ( center = center , radius = radius_m ) )
spheres_for_frequency . append (
Sphere ( center = receiver [ " center " ] , radius = radius_m )
)
row_receivers . append (
row_receivers . append (
{
{
" receiver_id " : str ( receiver [ " receiver_id " ] ) ,
" receiver_id " : str ( receiver [ " receiver_id " ] ) ,
@ -502,19 +702,7 @@ class AutoService:
tolerance = self . tolerance ,
tolerance = self . tolerance ,
z_preference = self . z_preference , # type: ignore[arg-type]
z_preference = self . z_preference , # type: ignore[arg-type]
)
)
for index , residual in enumerate ( result . residuals ) :
candidate_row = {
row_receivers [ index ] [ " residual_m " ] = residual
receiver_payloads [ index ] . setdefault ( " per_frequency " , [ ] ) . append (
{
" frequency_hz " : frequency_hz ,
" frequency_mhz " : frequency_hz / HZ_IN_MHZ ,
" radius_m " : spheres_for_frequency [ index ] . radius ,
" residual_m " : residual ,
" samples_count " : len ( grouped_by_receiver [ index ] [ frequency_hz ] ) ,
}
)
row = {
" frequency_hz " : frequency_hz ,
" frequency_hz " : frequency_hz ,
" frequency_mhz " : frequency_hz / HZ_IN_MHZ ,
" frequency_mhz " : frequency_hz / HZ_IN_MHZ ,
" position " : {
" position " : {
@ -525,16 +713,51 @@ class AutoService:
" exact " : result . exact ,
" exact " : result . exact ,
" rmse_m " : result . rmse ,
" rmse_m " : result . rmse ,
" receivers " : row_receivers ,
" receivers " : row_receivers ,
" used_receivers_count " : 3 ,
" available_receivers_count " : len ( available_indices ) ,
}
}
frequency_rows . append ( row )
if (
if best_row is None or float ( row [ " rmse_m " ] ) < float ( best_row [ " rmse_m " ] ) :
best_combo_row is None
best_row = row
or float ( candidate_row [ " rmse_m " ] ) < float ( best_combo_row [ " rmse_m " ] )
) :
best_combo_row = candidate_row
best_combo_result = result
best_combo_indices = combo
best_combo_spheres = spheres_for_frequency
if (
best_combo_row is None
or best_combo_result is None
or best_combo_indices is None
or best_combo_spheres is None
) :
continue
row_receivers = best_combo_row [ " receivers " ]
for local_index , receiver_index in enumerate ( best_combo_indices ) :
residual = best_combo_result . residuals [ local_index ]
row_receivers [ local_index ] [ " residual_m " ] = residual
receiver_payloads [ receiver_index ] . setdefault ( " per_frequency " , [ ] ) . append (
{
" frequency_hz " : frequency_hz ,
" frequency_mhz " : frequency_hz / HZ_IN_MHZ ,
" radius_m " : best_combo_spheres [ local_index ] . radius ,
" residual_m " : residual ,
" samples_count " : len ( grouped_by_receiver [ receiver_index ] [ frequency_hz ] ) ,
}
)
frequency_rows . append ( best_combo_row )
if best_row is None or float ( best_combo_row [ " rmse_m " ] ) < float ( best_row [ " rmse_m " ] ) :
best_row = best_combo_row
if best_row is None :
if best_row is None :
if len ( self . receivers ) == 3 :
raise RuntimeError ( " No common frequencies across all 3 receivers. " )
raise RuntimeError ( " Cannot build frequency table for trilateration. " )
raise RuntimeError ( " Cannot build frequency table for trilateration. " )
payload = {
payload = {
" timestamp_utc " : datetime . now ( timezone . utc ) . isoformat ( ) ,
" timestamp_utc " : _utc_now_iso_seconds ( ) ,
" selected_frequency_hz " : best_row [ " frequency_hz " ] ,
" selected_frequency_hz " : best_row [ " frequency_hz " ] ,
" selected_frequency_mhz " : float ( best_row [ " frequency_hz " ] ) / HZ_IN_MHZ ,
" selected_frequency_mhz " : float ( best_row [ " frequency_hz " ] ) / HZ_IN_MHZ ,
" position " : best_row [ " position " ] ,
" position " : best_row [ " position " ] ,
@ -556,109 +779,165 @@ class AutoService:
self . updated_at_utc = payload [ " timestamp_utc " ] # type: ignore[index]
self . updated_at_utc = payload [ " timestamp_utc " ] # type: ignore[index]
self . last_error = " "
self . last_error = " "
if self . output_enabled :
delivery = self . _deliver_to_output_servers ( payload )
output_payload = self . _build_output_payload ( payload )
if output_payload is None :
with self . state_lock :
with self . state_lock :
self . last_output_delivery = {
self . last_output_delivery = delivery
" enabled " : True ,
" status " : " skipped " ,
" http_status " : None ,
" response_body " : " No frequencies in configured output range " ,
" sent_at_utc " : datetime . now ( timezone . utc ) . isoformat ( ) ,
" target " : {
" ip " : self . output_ip ,
" port " : self . output_port ,
" path " : self . output_path ,
} ,
" frequency_filter " : {
" enabled " : self . output_frequency_filter_enabled ,
" min_frequency_mhz " : self . output_min_frequency_mhz ,
" max_frequency_mhz " : self . output_max_frequency_mhz ,
} ,
}
return
status_code , response_body = send_payload_to_server (
if delivery [ " status " ] in ( " error " , " partial " ) :
server_ip = self . output_ip ,
failed_servers = [
payload = output_payload ,
row [ " name " ]
port = self . output_port ,
for row in delivery . get ( " servers " , [ ] )
path = self . output_path ,
if isinstance ( row , dict ) and row . get ( " status " ) == " error "
timeout_s = self . output_timeout_s ,
]
)
# Keep delivery diagnostics in snapshot so UI/API can show transport health.
with self . state_lock :
self . last_output_delivery = {
" enabled " : True ,
" status " : " ok " if 200 < = status_code < 300 else " error " ,
" http_status " : status_code ,
" response_body " : response_body ,
" sent_at_utc " : datetime . now ( timezone . utc ) . isoformat ( ) ,
" target " : {
" ip " : self . output_ip ,
" port " : self . output_port ,
" path " : self . output_path ,
} ,
" frequency_filter " : {
" enabled " : self . output_frequency_filter_enabled ,
" min_frequency_mhz " : self . output_min_frequency_mhz ,
" max_frequency_mhz " : self . output_max_frequency_mhz ,
} ,
}
if status_code < 200 or status_code > = 300 :
raise RuntimeError (
raise RuntimeError (
" Output server rejected payload: "
" Output server(s) rejected payload: "
f " HTTP { status_code } , body= { response_body } "
+ " , " . join ( str ( name ) for name in failed_servers )
)
)
def _build_output_payload ( self , payload : Dict [ str , object ] ) - > Optional [ Dict [ str , object ] ] :
@staticmethod
if not self . output_frequency_filter_enabled :
def _row_frequency_mhz ( row : Dict [ str , object ] ) - > Optional [ float ] :
return payload
mhz = row . get ( " frequency_mhz " )
if isinstance ( mhz , ( int , float ) ) :
return float ( mhz )
hz = row . get ( " frequency_hz " )
if isinstance ( hz , ( int , float ) ) :
return float ( hz ) / HZ_IN_MHZ
return None
@staticmethod
def _position_from_row ( row : Dict [ str , object ] ) - > Optional [ Dict [ str , float ] ] :
position_obj = row . get ( " position " )
if not isinstance ( position_obj , dict ) :
return None
try :
return {
" x " : float ( position_obj [ " x " ] ) ,
" y " : float ( position_obj [ " y " ] ) ,
" z " : float ( position_obj [ " z " ] ) ,
}
except ( TypeError , ValueError , KeyError ) :
return None
# Keep internal calculations unchanged, but limit data sent to output server by frequency.
def _build_output_payload (
payload_copy = copy . deepcopy ( payload )
self ,
table_obj = payload_copy . get ( " frequency_table " )
payload : Dict [ str , object ] ,
output_server : Dict [ str , object ] ,
) - > Optional [ Dict [ str , object ] ] :
table_obj = payload . get ( " frequency_table " )
if not isinstance ( table_obj , list ) :
if not isinstance ( table_obj , list ) :
return None
return None
filtered_rows = [ ]
rows: List [ Dict [ str , object ] ] = [ ]
for row in table_obj :
for row in table_obj :
if not isinstance ( row , dict ) :
if not isinstance ( row , dict ) :
continue
continue
frequency_hz = row . get ( " frequency_hz " )
frequency_hz = row . get ( " frequency_hz " )
if not isinstance ( frequency_hz , ( int , float ) ) :
if not isinstance ( frequency_hz , ( int , float ) ) :
continue
continue
if self . output_min_frequency_hz < = float ( frequency_hz ) < = self . output_max_frequency_hz :
if self . _position_from_row ( row ) is None :
filtered_rows . append ( row )
continue
if not filtered_rows :
if bool ( output_server . get ( " frequency_filter_enabled " , False ) ) :
if not (
float ( output_server . get ( " min_frequency_hz " , 0.0 ) )
< = float ( frequency_hz )
< = float ( output_server . get ( " max_frequency_hz " , 0.0 ) )
) :
continue
rows . append ( row )
if not rows :
return None
return None
best_row = min ( filtered_rows , key = lambda row : float ( row . get ( " rmse_m " , float ( " inf " ) ) ) )
best_row = min (
payload_copy [ " frequency_table " ] = filtered_rows
rows ,
payload_copy [ " selected_frequency_hz " ] = best_row . get ( " frequency_hz " )
key = lambda row : float ( row . get ( " rmse_m " , float ( " inf " ) ) ) ,
payload_copy [ " selected_frequency_mhz " ] = float ( best_row . get ( " frequency_hz " , 0.0 ) ) / HZ_IN_MHZ
)
payload_copy [ " position " ] = best_row . get ( " position " )
best_position = self . _position_from_row ( best_row )
payload_copy [ " exact " ] = best_row . get ( " exact " )
if best_position is None :
payload_copy [ " rmse_m " ] = best_row . get ( " rmse_m " )
return None
receivers_obj = payload_copy . get ( " receivers " )
# Minimal transport payload for final server integration: coordinates only.
if isinstance ( receivers_obj , list ) :
return best_position
for receiver in receivers_obj :
if not isinstance ( receiver , dict ) :
def _deliver_to_output_servers ( self , payload : Dict [ str , object ] ) - > Dict [ str , object ] :
now = _utc_now_iso_seconds ( )
servers_delivery : List [ Dict [ str , object ] ] = [ ]
enabled_targets = [ server for server in self . output_servers if bool ( server . get ( " enabled " ) ) ]
for server in self . output_servers :
server_delivery = {
" name " : server [ " name " ] ,
" enabled " : bool ( server [ " enabled " ] ) ,
" status " : " disabled " ,
" http_status " : None ,
" response_body " : " " ,
" sent_at_utc " : now ,
" target " : {
" ip " : server [ " ip " ] ,
" port " : server [ " port " ] ,
" path " : server [ " path " ] ,
} ,
" frequency_filter " : {
" enabled " : server [ " frequency_filter_enabled " ] ,
" min_frequency_mhz " : server [ " min_frequency_mhz " ] ,
" max_frequency_mhz " : server [ " max_frequency_mhz " ] ,
} ,
}
if not bool ( server [ " enabled " ] ) :
servers_delivery . append ( server_delivery )
continue
continue
per_frequency = receiver . get ( " per_frequency " )
if not isinstance ( per_frequency , list ) :
output_payload = self . _build_output_payload ( payload = payload , output_server = server )
if output_payload is None :
server_delivery [ " status " ] = " skipped "
server_delivery [ " response_body " ] = " No frequencies in configured output range "
servers_delivery . append ( server_delivery )
continue
continue
receiver [ " per_frequency " ] = [
row
status_code , response_body = send_payload_to_server (
for row in per_frequency
server_ip = str ( server [ " ip " ] ) ,
if isinstance ( row , dict )
payload = output_payload ,
and isinstance ( row . get ( " frequency_hz " ) , ( int , float ) )
port = int ( server [ " port " ] ) ,
and self . output_min_frequency_hz
path = str ( server [ " path " ] ) ,
< = float ( row [ " frequency_hz " ] )
timeout_s = float ( server [ " timeout_s " ] ) ,
< = self . output_max_frequency_hz
)
]
server_delivery [ " http_status " ] = status_code
return payload_copy
server_delivery [ " response_body " ] = response_body
server_delivery [ " status " ] = " ok " if 200 < = status_code < 300 else " error "
servers_delivery . append ( server_delivery )
ok_count = sum ( 1 for row in servers_delivery if row [ " status " ] == " ok " )
error_count = sum ( 1 for row in servers_delivery if row [ " status " ] == " error " )
skipped_count = sum ( 1 for row in servers_delivery if row [ " status " ] == " skipped " )
if not enabled_targets :
status = " disabled "
elif error_count > 0 and ok_count > 0 :
status = " partial "
elif error_count > 0 :
status = " error "
elif ok_count == 0 and skipped_count > 0 :
status = " skipped "
else :
status = " ok "
primary = next ( ( row for row in servers_delivery if row [ " enabled " ] ) , None )
if primary is None and servers_delivery :
primary = servers_delivery [ 0 ]
return {
" enabled " : bool ( enabled_targets ) ,
" status " : status ,
" http_status " : None if primary is None else primary [ " http_status " ] ,
" response_body " : " " if primary is None else primary [ " response_body " ] ,
" sent_at_utc " : now ,
" target " : None if primary is None else primary [ " target " ] ,
" frequency_filter " : None if primary is None else primary [ " frequency_filter " ] ,
" ok_count " : ok_count ,
" error_count " : error_count ,
" skipped_count " : skipped_count ,
" servers " : servers_delivery ,
}
def _poll_loop ( self ) - > None :
def _poll_loop ( self ) - > None :
while not self . stop_event . is_set ( ) :
while not self . stop_event . is_set ( ) :
@ -680,9 +959,17 @@ class AutoService:
def _make_handler ( service : AutoService ) :
def _make_handler ( service : AutoService ) :
service_holder = { " current " : service }
service_swap_lock = threading . Lock ( )
class ServiceHandler ( BaseHTTPRequestHandler ) :
class ServiceHandler ( BaseHTTPRequestHandler ) :
@staticmethod
def _current_service ( ) - > AutoService :
return service_holder [ " current " ]
def _is_write_authorized ( self ) - > bool :
def _is_write_authorized ( self ) - > bool :
expected_token = service . write_api_token
service_obj = self . _current_service ( )
expected_token = service_obj . write_api_token
if not expected_token :
if not expected_token :
return True
return True
@ -733,6 +1020,12 @@ def _make_handler(service: AutoService):
mime_type , _ = mimetypes . guess_type ( str ( file_path ) )
mime_type , _ = mimetypes . guess_type ( str ( file_path ) )
if mime_type is None :
if mime_type is None :
mime_type = " application/octet-stream "
mime_type = " application/octet-stream "
# Force UTF-8 for text assets to avoid mojibake in browsers.
if mime_type . startswith ( " text/ " ) or mime_type in (
" application/javascript " ,
" application/x-javascript " ,
) :
mime_type = f " { mime_type } ; charset=utf-8 "
self . _write_bytes ( 200 , file_path . read_bytes ( ) , mime_type )
self . _write_bytes ( 200 , file_path . read_bytes ( ) , mime_type )
def log_message ( self , format : str , * args ) - > None :
def log_message ( self , format : str , * args ) - > None :
@ -740,7 +1033,8 @@ def _make_handler(service: AutoService):
def do_GET ( self ) - > None :
def do_GET ( self ) - > None :
path = parse . urlparse ( self . path ) . path
path = parse . urlparse ( self . path ) . path
snapshot = service . snapshot ( )
service_obj = self . _current_service ( )
snapshot = service_obj . snapshot ( )
if path == " / " or path == " /ui " :
if path == " / " or path == " /ui " :
self . _write_static ( " index.html " )
self . _write_static ( " index.html " )
@ -812,17 +1106,17 @@ def _make_handler(service: AutoService):
return
return
if path == " /config " :
if path == " /config " :
public_config = json . loads ( json . dumps ( service . config ) )
public_config = json . loads ( json . dumps ( service _obj . config ) )
runtime_obj = public_config . get ( " runtime " )
runtime_obj = public_config . get ( " runtime " )
if isinstance ( runtime_obj , dict ) :
if isinstance ( runtime_obj , dict ) :
if " write_api_token " in runtime_obj :
if " write_api_token " in runtime_obj :
runtime_obj [ " write_api_token " ] = " "
runtime_obj [ " write_api_token " ] = " "
runtime_obj [ " write_api_token_set " ] = bool ( service . write_api_token )
runtime_obj [ " write_api_token_set " ] = bool ( service _obj . write_api_token )
self . _write_json (
self . _write_json (
200 ,
200 ,
{
{
" status " : " ok " ,
" status " : " ok " ,
" config_path " : service . config_path ,
" config_path " : service _obj . config_path ,
" config " : public_config ,
" config " : public_config ,
} ,
} ,
)
)
@ -840,6 +1134,7 @@ def _make_handler(service: AutoService):
return
return
if path == " /config " :
if path == " /config " :
service_obj = self . _current_service ( )
try :
try :
content_length = int ( self . headers . get ( " Content-Length " , " 0 " ) )
content_length = int ( self . headers . get ( " Content-Length " , " 0 " ) )
except ValueError :
except ValueError :
@ -872,13 +1167,13 @@ def _make_handler(service: AutoService):
# Avoid accidental token wipe when /config GET response is redacted in clients.
# Avoid accidental token wipe when /config GET response is redacted in clients.
runtime_obj = new_config . get ( " runtime " )
runtime_obj = new_config . get ( " runtime " )
if isinstance ( runtime_obj , dict ) and service . write_api_token :
if isinstance ( runtime_obj , dict ) and service _obj . write_api_token :
incoming_token = str ( runtime_obj . get ( " write_api_token " , " " ) ) . strip ( )
incoming_token = str ( runtime_obj . get ( " write_api_token " , " " ) ) . strip ( )
if not incoming_token :
if not incoming_token :
runtime_obj [ " write_api_token " ] = service . write_api_token
runtime_obj [ " write_api_token " ] = service _obj . write_api_token
try :
try :
AutoService( new_config )
new_service = AutoService( new_config , config_path = service_obj . config_path )
except Exception as exc :
except Exception as exc :
self . _write_json (
self . _write_json (
400 ,
400 ,
@ -886,19 +1181,41 @@ def _make_handler(service: AutoService):
)
)
return
return
service . config = new_config
save_error = " "
if service . config_path :
if service_obj . config_path :
Path ( service . config_path ) . write_text (
try :
Path ( service_obj . config_path ) . write_text (
json . dumps ( new_config , ensure_ascii = False , indent = 2 ) ,
json . dumps ( new_config , ensure_ascii = False , indent = 2 ) ,
encoding = " utf-8 " ,
encoding = " utf-8 " ,
)
)
except OSError as exc :
save_error = str ( exc )
try :
new_service . start ( )
except Exception as exc :
self . _write_json (
500 ,
{
" status " : " error " ,
" error " : f " Failed to start service with new config: { exc } " ,
} ,
)
return
with service_swap_lock :
old_service = service_holder [ " current " ]
service_holder [ " current " ] = new_service
old_service . stop ( )
self . _write_json (
self . _write_json (
200 ,
200 ,
{
{
" status " : " ok " ,
" status " : " ok " ,
" saved " : bool ( service . config_path ) ,
" saved " : bool ( service_obj . config_path ) and not bool ( save_error ) ,
" restart_required " : True ,
" save_error " : save_error ,
" config_path " : service . config_path ,
" restart_required " : False ,
" applied " : True ,
" config_path " : service_obj . config_path ,
} ,
} ,
)
)
return
return
@ -908,12 +1225,12 @@ def _make_handler(service: AutoService):
return
return
try :
try :
service. refresh_once ( )
self . _current_ service( ) . refresh_once ( )
except Exception as exc :
except Exception as exc :
self . _write_json ( 500 , { " status " : " error " , " error " : str ( exc ) } )
self . _write_json ( 500 , { " status " : " error " , " error " : str ( exc ) } )
return
return
snapshot = service. snapshot ( )
snapshot = self . _current_ service( ) . snapshot ( )
self . _write_json (
self . _write_json (
200 ,
200 ,
{
{
@ -922,6 +1239,7 @@ def _make_handler(service: AutoService):
} ,
} ,
)
)
ServiceHandler . service_holder = service_holder # type: ignore[attr-defined]
return ServiceHandler
return ServiceHandler
@ -947,8 +1265,8 @@ def main() -> int:
service = AutoService ( config , config_path = args . config )
service = AutoService ( config , config_path = args . config )
service . start ( )
service . start ( )
handler = _make_handler ( service )
server = ThreadingHTTPServer ( ( host , port ) , _make_ handler( service ) )
server = ThreadingHTTPServer ( ( host , port ) , handler)
print ( f " service_listen: http:// { host } : { port } " )
print ( f " service_listen: http:// { host } : { port } " )
try :
try :
server . serve_forever ( )
server . serve_forever ( )
@ -956,7 +1274,8 @@ def main() -> int:
pass
pass
finally :
finally :
server . server_close ( )
server . server_close ( )
service . stop ( )
current_service = handler . service_holder [ " current " ] # type: ignore[attr-defined]
current_service . stop ( )
return 0
return 0