snapshot.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. package device
  2. import (
  3. "context"
  4. "encoding/csv"
  5. "fmt"
  6. "io"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "unicode"
  11. "vocat/internal/modem"
  12. )
  13. func (manager *Manager) readSnapshot(
  14. ctx context.Context,
  15. id string,
  16. candidate modem.Candidate,
  17. client modem.Client,
  18. ) (Snapshot, error) {
  19. snapshot := Snapshot{
  20. DeviceID: id,
  21. Port: candidate.ATPort.OpenPath(),
  22. OperatingMode: -1,
  23. UpdatedAt: time.Now().UTC(),
  24. }
  25. ati, err := manager.command(ctx, client, "ATI")
  26. if err != nil {
  27. return snapshot, fmt.Errorf("probe modem: %w", err)
  28. }
  29. snapshot.Responsive = true
  30. snapshot.Manufacturer, snapshot.Model, snapshot.Firmware = parseATI(ati.Lines)
  31. if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") {
  32. snapshot.Model = candidate.Product
  33. }
  34. optional := func(command string) (modem.Response, bool) {
  35. response, commandErr := manager.command(ctx, client, command)
  36. if commandErr != nil {
  37. snapshot.Warnings = append(snapshot.Warnings, commandErr.Error())
  38. return response, false
  39. }
  40. return response, true
  41. }
  42. if response, ok := optional("AT+CPIN?"); ok {
  43. snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
  44. }
  45. if response, ok := optional("AT+CSQ"); ok {
  46. snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
  47. }
  48. if response, ok := optional(`AT+QENG="servingcell"`); ok {
  49. metrics := parseQENG(response)
  50. snapshot.AccessTech = metrics.AccessTech
  51. snapshot.Band = metrics.Band
  52. snapshot.Channel = metrics.Channel
  53. snapshot.RSRP = metrics.RSRP
  54. snapshot.RSRQ = metrics.RSRQ
  55. snapshot.SINR = metrics.SINR
  56. if metrics.RSSI != nil {
  57. snapshot.RSSIDBm = metrics.RSSI
  58. }
  59. }
  60. if response, ok := optional("AT+COPS?"); ok {
  61. operator := parseCOPS(response)
  62. snapshot.OperatorName = operator.Name
  63. snapshot.OperatorCode = operator.Code
  64. if snapshot.AccessTech == "" {
  65. snapshot.AccessTech = operator.AccessTech
  66. }
  67. }
  68. if response, ok := optional("AT+CGSN"); ok {
  69. snapshot.IMEI = parseIdentifier(
  70. response,
  71. []string{"+CGSN:", "+GSN:"},
  72. 14,
  73. 17,
  74. )
  75. }
  76. ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
  77. if ccidErr != nil {
  78. ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
  79. }
  80. if ccidErr != nil {
  81. snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
  82. } else {
  83. snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
  84. }
  85. if response, ok := optional("AT+CIMI"); ok {
  86. snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
  87. }
  88. if response, ok := optional("AT+CFUN?"); ok {
  89. if mode, found := parseCFUN(response); found {
  90. snapshot.OperatingMode = mode
  91. snapshot.ModeKnown = true
  92. snapshot.FlightMode = isRadioOffMode(mode)
  93. snapshot.RadioOff = snapshot.FlightMode
  94. }
  95. }
  96. phone, warnings := manager.readPhoneNumber(ctx, client)
  97. snapshot.Phone = phone
  98. snapshot.Warnings = append(snapshot.Warnings, warnings...)
  99. snapshot.UpdatedAt = time.Now().UTC()
  100. return snapshot, nil
  101. }
  102. func parseATI(lines []string) (manufacturer, model, firmware string) {
  103. for _, line := range lines {
  104. line = strings.TrimSpace(line)
  105. upper := strings.ToUpper(line)
  106. switch {
  107. case strings.HasPrefix(upper, "REVISION:"):
  108. firmware = strings.TrimSpace(strings.SplitN(line, ":", 2)[1])
  109. case strings.Contains(upper, "QUECTEL"):
  110. manufacturer = line
  111. case strings.HasPrefix(upper, "EC20") || strings.HasPrefix(upper, "EC25"):
  112. model = line
  113. }
  114. }
  115. return
  116. }
  117. func parseCPIN(response modem.Response) (string, bool) {
  118. value := strings.ToUpper(valueAfterPrefix(response, "+CPIN:"))
  119. switch {
  120. case strings.Contains(value, "READY"):
  121. return "ready", true
  122. case strings.Contains(value, "SIM PIN"):
  123. return "pin_required", false
  124. case strings.Contains(value, "SIM PUK"):
  125. return "puk_required", false
  126. case strings.Contains(value, "NOT INSERTED"):
  127. return "not_inserted", false
  128. case value == "":
  129. return "unknown", false
  130. default:
  131. return strings.ToLower(strings.ReplaceAll(value, " ", "_")), false
  132. }
  133. }
  134. func parseCSQ(response modem.Response) (raw, percent, dbm *int) {
  135. values := csvValues(valueAfterPrefix(response, "+CSQ:"))
  136. if len(values) == 0 {
  137. return nil, nil, nil
  138. }
  139. value, err := strconv.Atoi(values[0])
  140. if err != nil || value < 0 || value > 31 {
  141. return nil, nil, nil
  142. }
  143. raw = intPointer(value)
  144. scaled := (value*100 + 15) / 31
  145. percent = intPointer(scaled)
  146. signalDBM := -113 + value*2
  147. dbm = intPointer(signalDBM)
  148. return
  149. }
  150. type qengMetrics struct {
  151. AccessTech string
  152. Band string
  153. Channel string
  154. RSSI *int
  155. RSRP *int
  156. RSRQ *int
  157. SINR *int
  158. }
  159. func parseQENG(response modem.Response) qengMetrics {
  160. for _, line := range response.Lines {
  161. if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "+QENG:") {
  162. continue
  163. }
  164. values := csvValues(strings.TrimSpace(strings.SplitN(line, ":", 2)[1]))
  165. if len(values) < 3 || !strings.EqualFold(values[0], "servingcell") {
  166. continue
  167. }
  168. result := qengMetrics{AccessTech: strings.ToUpper(values[2])}
  169. if strings.EqualFold(values[2], "LTE") && len(values) >= 17 {
  170. result.Channel = values[8]
  171. if values[9] != "" {
  172. result.Band = "B" + values[9]
  173. }
  174. result.RSRP = parseOptionalInt(values[13])
  175. result.RSRQ = parseOptionalInt(values[14])
  176. result.RSSI = parseOptionalInt(values[15])
  177. result.SINR = parseOptionalInt(values[16])
  178. }
  179. return result
  180. }
  181. return qengMetrics{}
  182. }
  183. type operatorInfo struct {
  184. Name string
  185. Code string
  186. AccessTech string
  187. }
  188. func parseCOPS(response modem.Response) operatorInfo {
  189. values := csvValues(valueAfterPrefix(response, "+COPS:"))
  190. if len(values) < 3 {
  191. return operatorInfo{}
  192. }
  193. result := operatorInfo{Name: values[2]}
  194. format, _ := strconv.Atoi(values[1])
  195. if format == 2 {
  196. result.Code = values[2]
  197. result.Name = ""
  198. }
  199. if len(values) >= 4 {
  200. result.AccessTech = accessTechnology(values[3])
  201. }
  202. return result
  203. }
  204. func accessTechnology(value string) string {
  205. switch strings.TrimSpace(value) {
  206. case "0":
  207. return "GSM"
  208. case "2":
  209. return "UTRAN"
  210. case "3":
  211. return "EDGE"
  212. case "4":
  213. return "HSDPA"
  214. case "5":
  215. return "HSUPA"
  216. case "6":
  217. return "HSPA"
  218. case "7":
  219. return "LTE"
  220. case "9":
  221. return "NR5G"
  222. default:
  223. return ""
  224. }
  225. }
  226. func parseCFUN(response modem.Response) (int, bool) {
  227. values := csvValues(valueAfterPrefix(response, "+CFUN:"))
  228. if len(values) == 0 {
  229. return 0, false
  230. }
  231. mode, err := strconv.Atoi(values[0])
  232. return mode, err == nil
  233. }
  234. func isRadioOffMode(mode int) bool {
  235. return mode == 0 || mode == 4
  236. }
  237. func valueAfterPrefix(response modem.Response, prefix string) string {
  238. for _, line := range response.Lines {
  239. line = strings.TrimSpace(line)
  240. if strings.HasPrefix(strings.ToUpper(line), strings.ToUpper(prefix)) {
  241. return strings.TrimSpace(line[len(prefix):])
  242. }
  243. }
  244. return ""
  245. }
  246. func csvValues(value string) []string {
  247. reader := csv.NewReader(strings.NewReader(value))
  248. reader.TrimLeadingSpace = true
  249. reader.LazyQuotes = true
  250. record, err := reader.Read()
  251. if err != nil && err != io.EOF {
  252. return nil
  253. }
  254. for index := range record {
  255. record[index] = strings.TrimSpace(record[index])
  256. }
  257. return record
  258. }
  259. func firstDigitLine(response modem.Response, minimum, maximum int) string {
  260. for _, line := range response.Lines {
  261. value := strings.TrimSpace(line)
  262. if len(value) < minimum || len(value) > maximum {
  263. continue
  264. }
  265. if strings.IndexFunc(value, func(character rune) bool {
  266. return !unicode.IsDigit(character)
  267. }) < 0 {
  268. return value
  269. }
  270. }
  271. return ""
  272. }
  273. func parseIdentifier(
  274. response modem.Response,
  275. prefixes []string,
  276. minimum, maximum int,
  277. ) string {
  278. for _, prefix := range prefixes {
  279. value := strings.Trim(valueAfterPrefix(response, prefix), `" `)
  280. if len(value) >= minimum && len(value) <= maximum &&
  281. strings.IndexFunc(value, func(character rune) bool {
  282. return !unicode.IsDigit(character)
  283. }) < 0 {
  284. return value
  285. }
  286. }
  287. return firstDigitLine(response, minimum, maximum)
  288. }
  289. // parseICCIDIdentifier accepts all trailing hexadecimal F nibbles exposed from
  290. // the fixed 10-octet EF-ICCID representation. A 19-digit ICCID has one filler
  291. // nibble while an 18-digit ICCID has two; neither is part of the identifier.
  292. func parseICCIDIdentifier(
  293. response modem.Response,
  294. prefixes []string,
  295. minimum, maximum int,
  296. ) string {
  297. normalize := func(value string) string {
  298. value = strings.Trim(value, `" `)
  299. value = strings.TrimRight(value, "Ff")
  300. if len(value) >= minimum && len(value) <= maximum &&
  301. strings.IndexFunc(value, func(character rune) bool { return !unicode.IsDigit(character) }) < 0 {
  302. return value
  303. }
  304. return ""
  305. }
  306. for _, prefix := range prefixes {
  307. if value := normalize(valueAfterPrefix(response, prefix)); value != "" {
  308. return value
  309. }
  310. }
  311. for _, line := range response.Lines {
  312. if value := normalize(strings.TrimSpace(line)); value != "" {
  313. return value
  314. }
  315. }
  316. return ""
  317. }
  318. func parseOptionalInt(value string) *int {
  319. number, err := strconv.Atoi(strings.TrimSpace(value))
  320. if err != nil {
  321. return nil
  322. }
  323. return intPointer(number)
  324. }
  325. func intPointer(value int) *int {
  326. return &value
  327. }