forked from sparky8512/starlink-grpc-tools
-
Notifications
You must be signed in to change notification settings - Fork 0
/
starlink_grpc.py
1619 lines (1370 loc) · 65.3 KB
/
starlink_grpc.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Helpers for grpc communication with a Starlink user terminal.
This module contains functions for getting the history and status data and
either return it as-is or parsed for some specific statistics, as well as a
handful of functions related to dish control.
The history and status functions return data grouped into sets, as follows.
Note:
Functions that return field names may indicate which fields hold sequences
(which are not necessarily lists) instead of single items. The field names
returned in those cases will be in one of the following formats:
: "name[]" : A sequence of indeterminate size (or a size that can be
determined from other parts of the returned data).
: "name[n]" : A sequence with exactly n elements.
: "name[n1,]" : A sequence of indeterminate size with recommended starting
index label n1.
: "name[n1,n2]" : A sequence with n2-n1 elements with recommended starting
index label n1. This is similar to the args to range() builtin.
For example, the field name "foo[1,5]" could be expanded to "foo_1",
"foo_2", "foo_3", and "foo_4" (or however else the caller wants to
indicate index numbers, if at all).
General status data
-------------------
This group holds information about the current state of the user terminal.
: **id** : A string identifying the specific user terminal device that was
reachable from the local network. Something like a serial number.
: **hardware_version** : A string identifying the user terminal hardware
version.
: **software_version** : A string identifying the software currently installed
on the user terminal.
: **state** : As string describing the current connectivity state of the user
terminal. One of: "UNKNOWN", "CONNECTED", "BOOTING", "SEARCHING", "STOWED",
"THERMAL_SHUTDOWN", "NO_SATS", "OBSTRUCTED", "NO_DOWNLINK", "NO_PINGS".
: **uptime** : The amount of time, in seconds, since the user terminal last
rebooted.
: **snr** : Most recent sample value. See bulk history data for detail.
**OBSOLETE**: The user terminal no longer provides this data.
: **seconds_to_first_nonempty_slot** : Amount of time from now, in seconds,
until a satellite will be scheduled to be available for transmit/receive.
See also *scheduled* in the bulk history data. May report as a negative
number, which appears to indicate unknown time until next satellite
scheduled and usually correlates with *state* reporting as other than
"CONNECTED".
: **pop_ping_drop_rate** : Most recent sample value. See bulk history data for
detail.
: **downlink_throughput_bps** : Most recent sample value. See bulk history
data for detail.
: **uplink_throughput_bps** : Most recent sample value. See bulk history data
for detail.
: **pop_ping_latency_ms** : Most recent sample value. See bulk history data
for detail.
: **alerts** : A bit field combining all active alerts, where a 1 bit
indicates the alert is active. See alert detail status data for which bits
correspond with each alert, or to get individual alert flags instead of a
combined bit mask.
: **fraction_obstructed** : The fraction of total area (or possibly fraction
of time?) that the user terminal has determined to be obstructed between
it and the satellites with which it communicates.
: **currently_obstructed** : Most recent sample value. See *obstructed* in
bulk history data for detail. This item still appears to be reported by
the user terminal despite no longer appearing in the bulk history data.
: **seconds_obstructed** : The amount of time within the history buffer,
in seconds, that the user terminal determined to be obstructed, regardless
of whether or not packets were able to be transmitted or received.
**OBSOLETE**: The user terminal no longer provides this data.
: **obstruction_duration** : Average consecutive time, in seconds, the user
terminal has detected its signal to be obstructed for a period of time
that it considers "prolonged", or None if no such obstructions were
recorded.
: **obstruction_interval** : Average time, in seconds, between the start of
such "prolonged" obstructions, or None if no such obstructions were
recorded.
: **direction_azimuth** : Azimuth angle, in degrees, of the direction in which
the user terminal's dish antenna is physically pointing. Note that this
generally is not the exact direction of the satellite with which the user
terminal is communicating.
: **direction_elevation** : Elevation angle, in degrees, of the direction in
which the user terminal's dish antenna is physically pointing.
: **is_snr_above_noise_floor** : Boolean indicating whether or not the dish
considers the signal to noise ratio to be above some minimum threshold for
connectivity, currently 3dB.
Obstruction detail status data
------------------------------
This group holds additional detail regarding the specific areas the user
terminal has determined to be obstructed.
: **wedges_fraction_obstructed** : A 12 element sequence. Each element
represents a 30 degree wedge of area and its value indicates the fraction
of area (time?) within that wedge that the user terminal has determined to
be obstructed between it and the satellites with which it communicates.
The values are expressed as a fraction of total, not a fraction of the
wedge, so max value for each element should be something like 1/12, but
may vary from wedge to wedge if they are weighted differently. The first
element in the sequence represents the wedge that spans exactly North to
30 degrees East of North, and subsequent wedges rotate 30 degrees further
in the same direction. (It's not clear if this will hold true at all
latitudes.)
**OBSOLETE**: The user terminal no longer provides this data.
: **raw_wedges_fraction_obstructed** : A 12 element sequence. Wedges
presumably correlate with the ones in *wedges_fraction_obstructed*, but
the exact relationship is unknown. The numbers in this one are generally
higher and may represent fraction of the wedge, in which case max value
for each element should be 1.
**OBSOLETE**: The user terminal no longer provides this data.
: **valid_s** : It is unclear what this field means exactly, but it appears to
be a measure of how complete the data is that the user terminal uses to
determine obstruction locations.
See also *fraction_obstructed* in general status data, which should equal the
sum of all *wedges_fraction_obstructed* elements.
Alert detail status data
------------------------
This group holds the current state of each individual alert reported by the
user terminal. Note that more alerts may be added in the future. See also
*alerts* in the general status data for a bit field combining them if you
need a set of fields that will not change size in the future.
Descriptions on these are vague due to them being difficult to confirm by
their nature, but the field names are pretty self-explanatory.
: **alert_motors_stuck** : Alert corresponding with bit 0 (bit mask 1) in
*alerts*.
: **alert_thermal_shutdown** : Alert corresponding with bit 1 (bit mask 2) in
*alerts*.
: **alert_thermal_throttle** : Alert corresponding with bit 2 (bit mask 4) in
*alerts*.
: **alert_unexpected_location** : Alert corresponding with bit 3 (bit mask 8)
in *alerts*.
: **alert_mast_not_near_vertical** : Alert corresponding with bit 4 (bit mask
16) in *alerts*.
: **alert_slow_ethernet_speeds** : Alert corresponding with bit 5 (bit mask
32) in *alerts*.
: **alert_roaming** : Alert corresponding with bit 6 (bit mask 64) in *alerts*.
: **alert_install_pending** : Alert corresponding with bit 7 (bit mask 128) in
*alerts*.
: **alert_is_heating** : Alert corresponding with bit 8 (bit mask 256) in
*alerts*.
: **alert_power_supply_thermal_throttle** : Alert corresponding with bit 9 (bit
mask 512) in *alerts*.
: **alert_is_power_save_idle** : Alert corresponding with bit 10 (bit mask
1024) in *alerts*.
: **alert_moving_while_not_mobile** : Alert corresponding with bit 11 (bit mask
2048) in *alerts*.
: **alert_moving_fast_while_not_aviation** : Alert corresponding with bit 12
(bit mask 4096) in *alerts*.
**OBSOLETE**: This alert is no longer generated, presumably in preference
of *alert_moving_too_fast_for_policy*.
: **alert_dbf_telem_stale** : Alert corresponding with bit 13 (bit mask 8192)
in *alerts*.
: **alert_moving_too_fast_for_policy** : Alert corresponding with bit 14 (bit
mask 16384) in *alerts*.
Location data
-------------
This group holds information about the physical location of the user terminal.
This group of fields should be considered EXPERIMENTAL, due to the requirement
to authorize access to location data on the user terminal.
: **latitude** : Latitude part of the current location, in degrees, or None if
location data is not available.
: **longitude** : Longitude part of the current location, in degrees, or None if
location data is not available.
: **altitude** : Altitude part of the current location, (probably) in meters, or
None if location data is not available.
General history data
--------------------
This set of fields contains data relevant to all the other history groups.
The sample interval is currently 1 second.
: **samples** : The number of samples analyzed (for statistics) or returned
(for bulk data).
: **end_counter** : The total number of data samples that have been written to
the history buffer since reboot of the user terminal, irrespective of
buffer wrap. This can be used to keep track of how many samples are new
in comparison to a prior query of the history data.
Bulk history data
-----------------
This group holds the history data as-is for the requested range of
samples, just unwound from the circular buffers that the raw data holds.
It contains some of the same fields as the status info, but instead of
representing the current values, each field contains a sequence of values
representing the value over time, ending at the current time.
: **pop_ping_drop_rate** : Fraction of lost ping replies per sample.
: **pop_ping_latency_ms** : Round trip time, in milliseconds, during the
sample period, or None if a sample experienced 100% ping drop.
: **downlink_throughput_bps** : Download usage during the sample period
(actual, not max available), in bits per second.
: **uplink_throughput_bps** : Upload usage during the sample period, in bits
per second.
: **snr** : Signal to noise ratio during the sample period.
**OBSOLETE**: The user terminal no longer provides this data.
: **scheduled** : Boolean indicating whether or not a satellite was scheduled
to be available for transmit/receive during the sample period. When
false, ping drop shows as "No satellites" in Starlink app.
**OBSOLETE**: The user terminal no longer provides this data.
: **obstructed** : Boolean indicating whether or not the user terminal
determined the signal between it and the satellite was obstructed during
the sample period. When true, ping drop shows as "Obstructed" in the
Starlink app.
**OBSOLETE**: The user terminal no longer provides this data.
There is no specific data field in the raw history data that directly
correlates with "Other" or "Beta downtime" in the Starlink app (or whatever it
gets renamed to after beta), but empirical evidence suggests any sample where
*pop_ping_drop_rate* is 1, *scheduled* is true, and *obstructed* is false is
counted as "Beta downtime".
Note that neither *scheduled*=false nor *obstructed*=true necessarily means
packet loss occurred. Those need to be examined in combination with
*pop_ping_drop_rate* to be meaningful.
General ping drop history statistics
------------------------------------
This group of statistics characterize the packet loss (labeled "ping drop" in
the field names of the Starlink gRPC service protocol) in various ways.
: **total_ping_drop** : The total amount of time, in sample intervals, that
experienced ping drop.
: **count_full_ping_drop** : The number of samples that experienced 100% ping
drop.
: **count_obstructed** : The number of samples that were marked as
"obstructed", regardless of whether they experienced any ping
drop.
**OBSOLETE**: The user terminal no longer provides the data from which
this was calculated.
: **total_obstructed_ping_drop** : The total amount of time, in sample
intervals, that experienced ping drop in samples marked as "obstructed".
**OBSOLETE**: The user terminal no longer provides the data from which
this was calculated.
: **count_full_obstructed_ping_drop** : The number of samples that were marked
as "obstructed" and that experienced 100% ping drop.
**OBSOLETE**: The user terminal no longer provides the data from which
this was calculated.
: **count_unscheduled** : The number of samples that were not marked as
"scheduled", regardless of whether they experienced any ping drop.
**OBSOLETE**: The user terminal no longer provides the data from which
this was calculated.
: **total_unscheduled_ping_drop** : The total amount of time, in sample
intervals, that experienced ping drop in samples not marked as
"scheduled".
**OBSOLETE**: The user terminal no longer provides the data from which
this was calculated.
: **count_full_unscheduled_ping_drop** : The number of samples that were not
marked as "scheduled" and that experienced 100% ping drop.
**OBSOLETE**: The user terminal no longer provides the data from which
this was calculated.
Total packet loss ratio can be computed with *total_ping_drop* / *samples*.
Ping drop run length history statistics
---------------------------------------
This group of statistics characterizes packet loss by how long a
consecutive run of 100% packet loss lasts.
: **init_run_fragment** : The number of consecutive sample periods at the
start of the sample set that experienced 100% ping drop. This period may
be a continuation of a run that started prior to the sample set, so is not
counted in the following stats.
: **final_run_fragment** : The number of consecutive sample periods at the end
of the sample set that experienced 100% ping drop. This period may
continue as a run beyond the end of the sample set, so is not counted in
the following stats.
: **run_seconds** : A 60 element sequence. Each element records the total
amount of time, in sample intervals, that experienced 100% ping drop in a
consecutive run that lasted for (index + 1) sample intervals (seconds).
That is, the first element contains time spent in 1 sample runs, the
second element contains time spent in 2 sample runs, etc.
: **run_minutes** : A 60 element sequence. Each element records the total
amount of time, in sample intervals, that experienced 100% ping drop in a
consecutive run that lasted for more that (index + 1) multiples of 60
sample intervals (minutes), but less than or equal to (index + 2)
multiples of 60 sample intervals. Except for the last element in the
sequence, which records the total amount of time in runs of more than
60*60 samples.
No sample should be counted in more than one of the run length stats or stat
elements, so the total of all of them should be equal to
*count_full_ping_drop* from the ping drop stats.
Samples that experience less than 100% ping drop are not counted in this group
of stats, even if they happen at the beginning or end of a run of 100% ping
drop samples. To compute the amount of time that experienced ping loss in less
than a single run of 100% ping drop, use (*total_ping_drop* -
*count_full_ping_drop*) from the ping drop stats.
Ping latency history statistics
-------------------------------
This group of statistics characterizes latency of ping request/response in
various ways. For all non-sequence fields and most sequence elements, the
value may report as None to indicate no matching samples. The exception is
*load_bucket_samples* elements, which report 0 for no matching samples.
The fields that have "all" in their name are computed across all samples that
had any ping success (ping drop < 1). The fields that have "full" in their
name are computed across only the samples that have 100% ping success (ping
drop = 0). Which one is more interesting may depend on intended use. High rate
of packet loss appears to cause outlier latency values on the high side. On
the one hand, those are real cases, so should not be dismissed lightly. On the
other hand, the "full" numbers are more directly comparable to sample sets
taken over time.
: **mean_all_ping_latency** : Weighted mean latency value, in milliseconds, of
all samples that experienced less than 100% ping drop. Values are weighted
by amount of ping success (1 - ping drop).
: **deciles_all_ping_latency** : An 11 element sequence recording the weighted
deciles (10-quantiles) of latency values, in milliseconds, for all samples
that experienced less that 100% ping drop, including the minimum and
maximum values as the 0th and 10th deciles respectively. The 5th decile
(at sequence index 5) is the weighted median latency value.
: **mean_full_ping_latency** : Mean latency value, in milliseconds, of samples
that experienced no ping drop.
: **deciles_full_ping_latency** : An 11 element sequence recording the deciles
(10-quantiles) of latency values, in milliseconds, for all samples that
experienced no ping drop, including the minimum and maximum values as the
0th and 10th deciles respectively. The 5th decile (at sequence index 5) is
the median latency value.
: **stdev_full_ping_latency** : Population standard deviation of the latency
value of samples that experienced no ping drop.
Loaded ping latency statistics
------------------------------
This group of statistics attempts to characterize latency of ping
request/response under various network load conditions. Samples are grouped by
total (down+up) bandwidth used during the sample period, using a log base 2
scale. These groups are referred to as "load buckets" below. The first bucket
in each sequence represents samples that use less than 1Mbps (millions of bits
per second). Subsequent buckets use more bandwidth than that covered by prior
buckets, but less than twice the maximum bandwidth of the immediately prior
bucket. The last bucket, at sequence index 14, represents all samples not
covered by a prior bucket, which works out to any sample using 8192Mbps or
greater. Only samples that experience no ping drop are included in any of the
buckets.
This group of fields should be considered EXPERIMENTAL and thus subject to
change without regard to backward compatibility.
Note that in all cases, the latency values are of "ping" traffic, which may be
prioritized lower than other traffic by various network layers. How much
bandwidth constitutes a fully loaded network connection may vary over time.
Buckets with few samples may not contain statistically significant latency
data.
: **load_bucket_samples** : A 15 element sequence recording the number of
samples per load bucket. See above for load bucket partitioning.
EXPERIMENTAL.
: **load_bucket_min_latency** : A 15 element sequence recording the minimum
latency value, in milliseconds, per load bucket. EXPERIMENTAL.
: **load_bucket_median_latency** : A 15 element sequence recording the median
latency value, in milliseconds, per load bucket. EXPERIMENTAL.
: **load_bucket_max_latency** : A 15 element sequence recording the maximum
latency value, in milliseconds, per load bucket. EXPERIMENTAL.
Bandwidth usage history statistics
----------------------------------
This group of statistics characterizes total bandwidth usage over the sample
period.
: **download_usage** : Total number of bytes downloaded to the user terminal
during the sample period.
: **upload_usage** : Total number of bytes uploaded from the user terminal
during the sample period.
"""
from itertools import chain
import math
import statistics
from typing import Dict, Iterable, List, Optional, Sequence, Tuple, get_type_hints
from typing_extensions import TypedDict, get_args
import grpc
try:
from yagrc import importer
importer.add_lazy_packages(["spacex.api.device"])
imports_pending = True
except (ImportError, AttributeError):
imports_pending = False
from spacex.api.device import device_pb2
from spacex.api.device import device_pb2_grpc
from spacex.api.device import dish_pb2
# Max wait time for gRPC request completion, in seconds. This is just to
# prevent hang if the connection goes dead without closing.
REQUEST_TIMEOUT = 10
HISTORY_FIELDS = ("pop_ping_drop_rate", "pop_ping_latency_ms", "downlink_throughput_bps",
"uplink_throughput_bps")
StatusDict = TypedDict(
"StatusDict", {
"id": str,
"hardware_version": str,
"software_version": str,
"state": str,
"uptime": int,
"snr": Optional[float],
"seconds_to_first_nonempty_slot": float,
"pop_ping_drop_rate": float,
"downlink_throughput_bps": float,
"uplink_throughput_bps": float,
"pop_ping_latency_ms": float,
"alerts": int,
"fraction_obstructed": float,
"currently_obstructed": bool,
"seconds_obstructed": Optional[float],
"obstruction_duration": Optional[float],
"obstruction_interval": Optional[float],
"direction_azimuth": float,
"direction_elevation": float,
"is_snr_above_noise_floor": bool,
})
ObstructionDict = TypedDict(
"ObstructionDict", {
"wedges_fraction_obstructed[]": Sequence[Optional[float]],
"raw_wedges_fraction_obstructed[]": Sequence[Optional[float]],
"valid_s": float,
})
AlertDict = Dict[str, bool]
LocationDict = TypedDict("LocationDict", {
"latitude": Optional[float],
"longitude": Optional[float],
"altitude": Optional[float],
})
HistGeneralDict = TypedDict("HistGeneralDict", {
"samples": int,
"end_counter": int,
})
HistBulkDict = TypedDict(
"HistBulkDict", {
"pop_ping_drop_rate": Sequence[float],
"pop_ping_latency_ms": Sequence[Optional[float]],
"downlink_throughput_bps": Sequence[float],
"uplink_throughput_bps": Sequence[float],
"snr": Sequence[Optional[float]],
"scheduled": Sequence[Optional[bool]],
"obstructed": Sequence[Optional[bool]],
})
PingDropDict = TypedDict(
"PingDropDict", {
"total_ping_drop": float,
"count_full_ping_drop": int,
"count_obstructed": int,
"total_obstructed_ping_drop": float,
"count_full_obstructed_ping_drop": int,
"count_unscheduled": int,
"total_unscheduled_ping_drop": float,
"count_full_unscheduled_ping_drop": int,
})
PingDropRlDict = TypedDict(
"PingDropRlDict", {
"init_run_fragment": int,
"final_run_fragment": int,
"run_seconds[1,]": Sequence[int],
"run_minutes[1,]": Sequence[int],
})
PingLatencyDict = TypedDict(
"PingLatencyDict", {
"mean_all_ping_latency": float,
"deciles_all_ping_latency[]": Sequence[float],
"mean_full_ping_latency": float,
"deciles_full_ping_latency[]": Sequence[float],
"stdev_full_ping_latency": Optional[float],
})
LoadedLatencyDict = TypedDict(
"LoadedLatencyDict", {
"load_bucket_samples[]": Sequence[int],
"load_bucket_min_latency[]": Sequence[Optional[float]],
"load_bucket_median_latency[]": Sequence[Optional[float]],
"load_bucket_max_latency[]": Sequence[Optional[float]],
})
UsageDict = TypedDict("UsageDict", {
"download_usage": int,
"upload_usage": int,
})
# For legacy reasons, there is a slight difference between the field names
# returned in the actual data vs the *_field_names functions. This is a map of
# the differences. Bulk data fields are handled separately because the field
# "snr" overlaps with a status field and needs to map differently.
_FIELD_NAME_MAP = {
"wedges_fraction_obstructed[]": "wedges_fraction_obstructed[12]",
"raw_wedges_fraction_obstructed[]": "raw_wedges_fraction_obstructed[12]",
"run_seconds[1,]": "run_seconds[1,61]",
"run_minutes[1,]": "run_minutes[1,61]",
"deciles_all_ping_latency[]": "deciles_all_ping_latency[11]",
"deciles_full_ping_latency[]": "deciles_full_ping_latency[11]",
"load_bucket_samples[]": "load_bucket_samples[15]",
"load_bucket_min_latency[]": "load_bucket_min_latency[15]",
"load_bucket_median_latency[]": "load_bucket_median_latency[15]",
"load_bucket_max_latency[]": "load_bucket_max_latency[15]",
}
def _field_names(hint_type):
return list(_FIELD_NAME_MAP.get(key, key) for key in get_type_hints(hint_type))
def _field_names_bulk(hint_type):
return list(key + "[]" for key in get_type_hints(hint_type))
def _field_types(hint_type):
def xlate(value):
while not isinstance(value, type):
args = get_args(value)
value = args[0] if args[0] is not type(None) else args[1]
return value
return list(xlate(val) for val in get_type_hints(hint_type).values())
def resolve_imports(channel: grpc.Channel):
importer.resolve_lazy_imports(channel)
global imports_pending
imports_pending = False
class GrpcError(Exception):
"""Provides error info when something went wrong with a gRPC call."""
def __init__(self, e, *args, **kwargs):
# grpc.RpcError is too verbose to print in whole, but it may also be
# a Call object, and that class has some minimally useful info.
if isinstance(e, grpc.Call):
msg = e.details()
elif isinstance(e, grpc.RpcError):
msg = "Unknown communication or service error"
elif isinstance(e, (AttributeError, IndexError, TypeError, ValueError)):
msg = "Protocol error"
else:
msg = str(e)
super().__init__(msg, *args, **kwargs)
class UnwrappedHistory:
"""Class for holding a copy of grpc history data."""
unwrapped: bool
class ChannelContext:
"""A wrapper for reusing an open grpc Channel across calls.
`close()` should be called on the object when it is no longer
in use.
"""
def __init__(self, target: Optional[str] = None) -> None:
self.channel = None
self.target = "192.168.100.1:9200" if target is None else target
def get_channel(self) -> Tuple[grpc.Channel, bool]:
reused = True
if self.channel is None:
self.channel = grpc.insecure_channel(self.target)
reused = False
return self.channel, reused
def close(self) -> None:
if self.channel is not None:
self.channel.close()
self.channel = None
def call_with_channel(function, *args, context: Optional[ChannelContext] = None, **kwargs):
"""Call a function with a channel object.
Args:
function: Function to call with channel as first arg.
args: Additional args to pass to function
context (ChannelContext): Optionally provide a channel for (re)use.
If not set, a new default channel will be used and then closed.
kwargs: Additional keyword args to pass to function.
"""
if context is None:
with grpc.insecure_channel("192.168.100.1:9200") as channel:
return function(channel, *args, **kwargs)
while True:
channel, reused = context.get_channel()
try:
return function(channel, *args, **kwargs)
except grpc.RpcError:
context.close()
if not reused:
raise
def status_field_names(context: Optional[ChannelContext] = None):
"""Return the field names of the status data.
Note:
See module level docs regarding brackets in field names.
Args:
context (ChannelContext): Optionally provide a channel for (re)use
with reflection service.
Returns:
A tuple with 3 lists, with status data field names, obstruction detail
field names, and alert detail field names, in that order.
Raises:
GrpcError: No user terminal is currently available to resolve imports
via reflection.
"""
if imports_pending:
try:
call_with_channel(resolve_imports, context=context)
except grpc.RpcError as e:
raise GrpcError(e) from e
alert_names = []
try:
for field in dish_pb2.DishAlerts.DESCRIPTOR.fields:
alert_names.append("alert_" + field.name)
except AttributeError:
pass
return _field_names(StatusDict), _field_names(ObstructionDict), alert_names
def status_field_types(context: Optional[ChannelContext] = None):
"""Return the field types of the status data.
Return the type classes for each field. For sequence types, the type of
element in the sequence is returned, not the type of the sequence.
Args:
context (ChannelContext): Optionally provide a channel for (re)use
with reflection service.
Returns:
A tuple with 3 lists, with status data field types, obstruction detail
field types, and alert detail field types, in that order.
Raises:
GrpcError: No user terminal is currently available to resolve imports
via reflection.
"""
if imports_pending:
try:
call_with_channel(resolve_imports, context=context)
except grpc.RpcError as e:
raise GrpcError(e) from e
num_alerts = 0
try:
num_alerts = len(dish_pb2.DishAlerts.DESCRIPTOR.fields)
except AttributeError:
pass
return (_field_types(StatusDict), _field_types(ObstructionDict), [bool] * num_alerts)
def get_status(context: Optional[ChannelContext] = None):
"""Fetch status data and return it in grpc structure format.
Args:
context (ChannelContext): Optionally provide a channel for reuse
across repeated calls. If an existing channel is reused, the RPC
call will be retried at most once, since connectivity may have
been lost and restored in the time since it was last used.
Raises:
grpc.RpcError: Communication or service error.
AttributeError, ValueError: Protocol error. Either the target is not a
Starlink user terminal or the grpc protocol has changed in a way
this module cannot handle.
"""
def grpc_call(channel):
if imports_pending:
resolve_imports(channel)
stub = device_pb2_grpc.DeviceStub(channel)
response = stub.Handle(device_pb2.Request(get_status={}), timeout=REQUEST_TIMEOUT)
return response.dish_get_status
return call_with_channel(grpc_call, context=context)
def get_id(context: Optional[ChannelContext] = None) -> str:
"""Return the ID from the dish status information.
Args:
context (ChannelContext): Optionally provide a channel for reuse
across repeated calls.
Returns:
A string identifying the Starlink user terminal reachable from the
local network.
Raises:
GrpcError: No user terminal is currently reachable.
"""
try:
status = get_status(context)
return status.device_info.id
except (AttributeError, ValueError, grpc.RpcError) as e:
raise GrpcError(e) from e
def status_data(
context: Optional[ChannelContext] = None) -> Tuple[StatusDict, ObstructionDict, AlertDict]:
"""Fetch current status data.
Args:
context (ChannelContext): Optionally provide a channel for reuse
across repeated calls.
Returns:
A tuple with 3 dicts, mapping status data field names, obstruction
detail field names, and alert detail field names to their respective
values, in that order.
Raises:
GrpcError: Failed getting status info from the Starlink user terminal.
"""
try:
status = get_status(context)
except (AttributeError, ValueError, grpc.RpcError) as e:
raise GrpcError(e) from e
try:
if status.HasField("outage"):
if status.outage.cause == dish_pb2.DishOutage.Cause.NO_SCHEDULE:
# Special case translate this to equivalent old name
state = "SEARCHING"
else:
try:
state = dish_pb2.DishOutage.Cause.Name(status.outage.cause)
except ValueError:
# Unlikely, but possible if dish is running newer firmware
# than protocol data pulled via reflection
state = str(status.outage.cause)
else:
state = "CONNECTED"
except (AttributeError, ValueError):
state = "UNKNOWN"
# More alerts may be added in future, so in addition to listing them
# individually, provide a bit field based on field numbers of the
# DishAlerts message.
alerts = {}
alert_bits = 0
try:
for field in status.alerts.DESCRIPTOR.fields:
value = getattr(status.alerts, field.name, False)
alerts["alert_" + field.name] = value
if field.number < 65:
alert_bits |= (1 if value else 0) << (field.number - 1)
except AttributeError:
pass
obstruction_duration = None
obstruction_interval = None
obstruction_stats = getattr(status, "obstruction_stats", None)
if obstruction_stats is not None:
try:
if (obstruction_stats.avg_prolonged_obstruction_duration_s > 0.0
and not math.isnan(obstruction_stats.avg_prolonged_obstruction_interval_s)):
obstruction_duration = obstruction_stats.avg_prolonged_obstruction_duration_s
obstruction_interval = obstruction_stats.avg_prolonged_obstruction_interval_s
except AttributeError:
pass
device_info = getattr(status, "device_info", None)
return {
"id": getattr(device_info, "id", None),
"hardware_version": getattr(device_info, "hardware_version", None),
"software_version": getattr(device_info, "software_version", None),
"state": state,
"uptime": getattr(getattr(status, "device_state", None), "uptime_s", None),
"snr": None, # obsoleted in grpc service
"seconds_to_first_nonempty_slot": getattr(status, "seconds_to_first_nonempty_slot", None),
"pop_ping_drop_rate": getattr(status, "pop_ping_drop_rate", None),
"downlink_throughput_bps": getattr(status, "downlink_throughput_bps", None),
"uplink_throughput_bps": getattr(status, "uplink_throughput_bps", None),
"pop_ping_latency_ms": getattr(status, "pop_ping_latency_ms", None),
"alerts": alert_bits,
"fraction_obstructed": getattr(obstruction_stats, "fraction_obstructed", None),
"currently_obstructed": getattr(obstruction_stats, "currently_obstructed", None),
"seconds_obstructed": None, # obsoleted in grpc service
"obstruction_duration": obstruction_duration,
"obstruction_interval": obstruction_interval,
"direction_azimuth": getattr(status, "boresight_azimuth_deg", None),
"direction_elevation": getattr(status, "boresight_elevation_deg", None),
"is_snr_above_noise_floor": getattr(status, "is_snr_above_noise_floor", None),
}, {
"wedges_fraction_obstructed[]": [None] * 12, # obsoleted in grpc service
"raw_wedges_fraction_obstructed[]": [None] * 12, # obsoleted in grpc service
"valid_s": getattr(obstruction_stats, "valid_s", None),
}, alerts
def location_field_names():
"""Return the field names of the location data.
Returns:
A list with location data field names.
"""
return _field_names(LocationDict)
def location_field_types():
"""Return the field types of the location data.
Return the type classes for each field.
Returns:
A list with location data field types.
"""
return _field_types(LocationDict)
def get_location(context: Optional[ChannelContext] = None):
"""Fetch location data and return it in grpc structure format.
Args:
context (ChannelContext): Optionally provide a channel for reuse
across repeated calls. If an existing channel is reused, the RPC
call will be retried at most once, since connectivity may have
been lost and restored in the time since it was last used.
Raises:
grpc.RpcError: Communication or service error.
AttributeError, ValueError: Protocol error. Either the target is not a
Starlink user terminal or the grpc protocol has changed in a way
this module cannot handle.
"""
def grpc_call(channel):
if imports_pending:
resolve_imports(channel)
stub = device_pb2_grpc.DeviceStub(channel)
response = stub.Handle(device_pb2.Request(get_location={}), timeout=REQUEST_TIMEOUT)
return response.get_location
return call_with_channel(grpc_call, context=context)
def location_data(context: Optional[ChannelContext] = None) -> LocationDict:
"""Fetch current location data.
Args:
context (ChannelContext): Optionally provide a channel for reuse
across repeated calls.
Returns:
A dict mapping location data field names to their values. Values will
be set to None in the case that location request is not enabled (ie:
not authorized).
Raises:
GrpcError: Failed getting location info from the Starlink user terminal.
"""
try:
location = get_location(context)
except (AttributeError, ValueError, grpc.RpcError) as e:
if isinstance(e, grpc.Call) and e.code() is grpc.StatusCode.PERMISSION_DENIED:
return {
"latitude": None,
"longitude": None,
"altitude": None,
}
raise GrpcError(e) from e
try:
return {
"latitude": location.lla.lat,
"longitude": location.lla.lon,
"altitude": getattr(location.lla, "alt", None),
}
except AttributeError as e:
# Allow None for altitude, but since all None values has special
# meaning for this function, any other protocol change is flagged as
# an error.
raise GrpcError(e) from e
def history_bulk_field_names():
"""Return the field names of the bulk history data.
Note:
See module level docs regarding brackets in field names.
Returns:
A tuple with 2 lists, the first with general data names, the second
with bulk history data names.
"""
return _field_names(HistGeneralDict), _field_names_bulk(HistBulkDict)
def history_bulk_field_types():
"""Return the field types of the bulk history data.
Return the type classes for each field. For sequence types, the type of
element in the sequence is returned, not the type of the sequence.
Returns:
A tuple with 2 lists, the first with general data types, the second
with bulk history data types.
"""
return _field_types(HistGeneralDict), _field_types(HistBulkDict)
def history_ping_field_names():
"""Deprecated. Use history_stats_field_names instead."""
return history_stats_field_names()[0:3]
def history_stats_field_names():
"""Return the field names of the packet loss stats.
Note:
See module level docs regarding brackets in field names.
Returns:
A tuple with 6 lists, with general data names, ping drop stat names,
ping drop run length stat names, ping latency stat names, loaded ping
latency stat names, and bandwidth usage stat names, in that order.
Note:
Additional lists may be added to this tuple in the future with
additional data groups, so it not recommended for the caller to
assume exactly 6 elements.
"""
return (_field_names(HistGeneralDict), _field_names(PingDropDict), _field_names(PingDropRlDict),
_field_names(PingLatencyDict), _field_names(LoadedLatencyDict), _field_names(UsageDict))
def history_stats_field_types():
"""Return the field types of the packet loss stats.
Return the type classes for each field. For sequence types, the type of
element in the sequence is returned, not the type of the sequence.
Returns:
A tuple with 6 lists, with general data types, ping drop stat types,
ping drop run length stat types, ping latency stat types, loaded ping
latency stat types, and bandwidth usage stat types, in that order.
Note:
Additional lists may be added to this tuple in the future with
additional data groups, so it not recommended for the caller to
assume exactly 6 elements.
"""
return (_field_types(HistGeneralDict), _field_types(PingDropDict), _field_types(PingDropRlDict),
_field_types(PingLatencyDict), _field_types(LoadedLatencyDict), _field_types(UsageDict))
def get_history(context: Optional[ChannelContext] = None):
"""Fetch history data and return it in grpc structure format.
Args:
context (ChannelContext): Optionally provide a channel for reuse
across repeated calls. If an existing channel is reused, the RPC
call will be retried at most once, since connectivity may have
been lost and restored in the time since it was last used.
Raises:
grpc.RpcError: Communication or service error.
AttributeError, ValueError: Protocol error. Either the target is not a
Starlink user terminal or the grpc protocol has changed in a way
this module cannot handle.
"""
def grpc_call(channel: grpc.Channel):
if imports_pending:
resolve_imports(channel)
stub = device_pb2_grpc.DeviceStub(channel)
response = stub.Handle(device_pb2.Request(get_history={}), timeout=REQUEST_TIMEOUT)
return response.dish_get_history
return call_with_channel(grpc_call, context=context)
def _compute_sample_range(history,
parse_samples: int,
start: Optional[int] = None,
verbose: bool = False):
try:
current = int(history.current)
samples = len(history.pop_ping_drop_rate)
except (AttributeError, TypeError):