controls.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. package device
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/hex"
  6. "errors"
  7. "fmt"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "unicode/utf16"
  12. "vocat/internal/modem"
  13. )
  14. // USSD dialog states derived from the +CUSD <n> result code.
  15. const (
  16. ussdStatusFinal = "final" // 0: no further user action required
  17. ussdStatusAwaitingInput = "awaiting_input" // 1: network expects more input
  18. ussdStatusTerminated = "terminated" // 2: terminated by the network
  19. ussdStatusFailed = "failed" // 3/4/5: local answer, unsupported, or timeout
  20. )
  21. // USSD begins a USSD dialog. When the network expects more input the result
  22. // carries an "awaiting_input" status and a session id that can be continued or
  23. // cancelled; otherwise the dialog is complete and self-contained.
  24. func (manager *Manager) USSD(
  25. ctx context.Context,
  26. id string,
  27. code string,
  28. ) (USSDResult, error) {
  29. code = strings.TrimSpace(code)
  30. if !validServiceCode(code) {
  31. return USSDResult{}, errors.New("invalid USSD service code")
  32. }
  33. result, err := manager.runUSSD(ctx, id, fmt.Sprintf(`AT+CUSD=1,"%s",15`, code))
  34. if err != nil {
  35. return result, err
  36. }
  37. result.Code = code
  38. if result.Status == ussdStatusAwaitingInput {
  39. result.SessionID = manager.openUSSDSession(id)
  40. result.Continueable = true
  41. }
  42. return result, nil
  43. }
  44. // ContinueUSSD sends follow-up input on an open USSD dialog.
  45. func (manager *Manager) ContinueUSSD(
  46. ctx context.Context,
  47. sessionID string,
  48. input string,
  49. ) (USSDResult, error) {
  50. deviceID, err := manager.ussdSessionDevice(sessionID)
  51. if err != nil {
  52. return USSDResult{}, err
  53. }
  54. input = strings.TrimSpace(input)
  55. if input == "" || len(input) > 182 || strings.ContainsAny(input, "\"\r\n") {
  56. return USSDResult{}, errors.New("invalid USSD input")
  57. }
  58. result, err := manager.runUSSD(ctx, deviceID, fmt.Sprintf(`AT+CUSD=1,"%s",15`, input))
  59. if err != nil {
  60. return result, err
  61. }
  62. result.Code = input
  63. if result.Status == ussdStatusAwaitingInput {
  64. result.SessionID = sessionID
  65. result.Continueable = true
  66. } else {
  67. manager.dropUSSDSession(sessionID)
  68. }
  69. return result, nil
  70. }
  71. // CancelUSSD terminates an open USSD dialog with AT+CUSD=2. A session abort
  72. // returns OK without a +CUSD result, so unlike start/continue it does not wait
  73. // for an unsolicited response.
  74. func (manager *Manager) CancelUSSD(ctx context.Context, sessionID string) error {
  75. deviceID, err := manager.ussdSessionDevice(sessionID)
  76. if err != nil {
  77. return err
  78. }
  79. defer manager.dropUSSDSession(sessionID)
  80. state, err := manager.lookup(deviceID)
  81. if err != nil {
  82. return err
  83. }
  84. state.opMu.Lock()
  85. defer state.opMu.Unlock()
  86. if err := manager.validateActive(deviceID, state); err != nil {
  87. return err
  88. }
  89. client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
  90. if err != nil {
  91. manager.setResult(deviceID, state, nil, err)
  92. return err
  93. }
  94. commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
  95. _, err = client.Execute(commandCtx, "AT+CUSD=2")
  96. cancel()
  97. manager.setResult(deviceID, state, nil, err)
  98. return err
  99. }
  100. // runUSSD issues one CUSD command and waits for the +CUSD unsolicited result.
  101. func (manager *Manager) runUSSD(
  102. ctx context.Context,
  103. id string,
  104. command string,
  105. ) (USSDResult, error) {
  106. state, err := manager.lookup(id)
  107. if err != nil {
  108. return USSDResult{}, err
  109. }
  110. state.opMu.Lock()
  111. defer state.opMu.Unlock()
  112. if err := manager.validateActive(id, state); err != nil {
  113. return USSDResult{}, err
  114. }
  115. client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
  116. if err != nil {
  117. manager.setResult(id, state, nil, err)
  118. return USSDResult{}, err
  119. }
  120. commandCtx, cancelCommand := manager.withTimeout(ctx, manager.commandTimeout)
  121. _, err = client.Execute(commandCtx, command)
  122. cancelCommand()
  123. if err != nil {
  124. manager.setResult(id, state, nil, err)
  125. return USSDResult{}, err
  126. }
  127. waitCtx, cancelWait := manager.withTimeout(ctx, manager.longTimeout)
  128. defer cancelWait()
  129. line, err := client.WaitURC(waitCtx, func(line string) bool {
  130. return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "+CUSD:")
  131. })
  132. if err != nil {
  133. manager.setResult(id, state, nil, err)
  134. return USSDResult{}, err
  135. }
  136. result, err := parseUSSDResponse(line)
  137. manager.setResult(id, state, nil, err)
  138. return result, err
  139. }
  140. func (manager *Manager) openUSSDSession(deviceID string) string {
  141. var token [8]byte
  142. _, _ = rand.Read(token[:])
  143. id := hex.EncodeToString(token[:])
  144. manager.mu.Lock()
  145. manager.ussdSessions[id] = ussdSession{deviceID: deviceID, createdAt: time.Now().UTC()}
  146. manager.mu.Unlock()
  147. return id
  148. }
  149. func (manager *Manager) ussdSessionDevice(sessionID string) (string, error) {
  150. manager.mu.RLock()
  151. session, ok := manager.ussdSessions[strings.TrimSpace(sessionID)]
  152. manager.mu.RUnlock()
  153. if !ok {
  154. return "", ErrUSSDSessionNotFound
  155. }
  156. return session.deviceID, nil
  157. }
  158. func (manager *Manager) dropUSSDSession(sessionID string) {
  159. manager.mu.Lock()
  160. delete(manager.ussdSessions, strings.TrimSpace(sessionID))
  161. manager.mu.Unlock()
  162. }
  163. // parseUSSDResponse parses a +CUSD unsolicited result line, capturing the dialog
  164. // state byte, the decoded text, and the data coding scheme.
  165. func parseUSSDResponse(line string) (USSDResult, error) {
  166. if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "+CUSD:") {
  167. return USSDResult{}, errors.New("invalid CUSD response")
  168. }
  169. values := csvValues(strings.TrimSpace(strings.SplitN(line, ":", 2)[1]))
  170. if len(values) < 2 {
  171. return USSDResult{}, errors.New("CUSD response has no text")
  172. }
  173. code, _ := strconv.Atoi(strings.TrimSpace(values[0]))
  174. payload := strings.Trim(values[1], `"`)
  175. var dcs *int
  176. if len(values) >= 3 {
  177. if value, err := strconv.Atoi(values[2]); err == nil {
  178. dcs = intPointer(value)
  179. }
  180. }
  181. text := payload
  182. if (dcs != nil && *dcs == 72) || looksLikeUCS2(payload) {
  183. if decoded := decodeUCS2(payload); decoded != "" {
  184. text = decoded
  185. }
  186. }
  187. return USSDResult{
  188. Text: text,
  189. Raw: line,
  190. DCS: dcs,
  191. Status: ussdStatusFromCode(code),
  192. }, nil
  193. }
  194. func ussdStatusFromCode(code int) string {
  195. switch code {
  196. case 0:
  197. return ussdStatusFinal
  198. case 1:
  199. return ussdStatusAwaitingInput
  200. case 2:
  201. return ussdStatusTerminated
  202. default:
  203. return ussdStatusFailed
  204. }
  205. }
  206. func looksLikeUCS2(value string) bool {
  207. if len(value) < 4 || len(value)%4 != 0 {
  208. return false
  209. }
  210. if _, err := hex.DecodeString(value); err != nil {
  211. return false
  212. }
  213. return strings.HasPrefix(value, "00") ||
  214. strings.IndexFunc(value, func(character rune) bool {
  215. return character >= 'A' && character <= 'F' ||
  216. character >= 'a' && character <= 'f'
  217. }) >= 0
  218. }
  219. func decodeUCS2(value string) string {
  220. if len(value) < 4 || len(value)%4 != 0 {
  221. return ""
  222. }
  223. units := make([]uint16, 0, len(value)/4)
  224. for index := 0; index < len(value); index += 4 {
  225. unit, err := strconv.ParseUint(value[index:index+4], 16, 16)
  226. if err != nil {
  227. return ""
  228. }
  229. units = append(units, uint16(unit))
  230. }
  231. return string(utf16.Decode(units))
  232. }
  233. func validServiceCode(value string) bool {
  234. if value == "" || len(value) > 40 {
  235. return false
  236. }
  237. for _, character := range value {
  238. if character < '0' || character > '9' {
  239. if character != '*' && character != '#' && character != '+' {
  240. return false
  241. }
  242. }
  243. }
  244. return true
  245. }
  246. func (manager *Manager) SetFlight(
  247. ctx context.Context,
  248. id string,
  249. enabled bool,
  250. ) (FlightResult, error) {
  251. state, err := manager.lookup(id)
  252. if err != nil {
  253. return FlightResult{}, err
  254. }
  255. state.opMu.Lock()
  256. defer state.opMu.Unlock()
  257. if err := manager.validateActive(id, state); err != nil {
  258. return FlightResult{}, err
  259. }
  260. client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
  261. if err != nil {
  262. manager.setResult(id, state, nil, err)
  263. return FlightResult{}, err
  264. }
  265. previous, err := manager.readOperatingMode(ctx, client)
  266. if err != nil {
  267. manager.setResult(id, state, nil, err)
  268. return FlightResult{}, err
  269. }
  270. target := previous
  271. if enabled {
  272. if !isRadioOffMode(previous) {
  273. saved := previous
  274. state.preFlightMode = &saved
  275. target = 4
  276. }
  277. } else if isRadioOffMode(previous) {
  278. target = 1
  279. if state.preFlightMode != nil && !isRadioOffMode(*state.preFlightMode) {
  280. target = *state.preFlightMode
  281. }
  282. }
  283. changed := target != previous
  284. if changed {
  285. if _, err := manager.command(
  286. ctx,
  287. client,
  288. fmt.Sprintf("AT+CFUN=%d", target),
  289. ); err != nil {
  290. manager.setResult(id, state, nil, err)
  291. return FlightResult{
  292. PreviousMode: previous,
  293. CurrentMode: previous,
  294. FlightMode: isRadioOffMode(previous),
  295. RadioOff: isRadioOffMode(previous),
  296. }, err
  297. }
  298. }
  299. current, err := manager.readOperatingMode(ctx, client)
  300. if err != nil {
  301. manager.setResult(id, state, nil, err)
  302. return FlightResult{
  303. PreviousMode: previous,
  304. CurrentMode: target,
  305. Changed: changed,
  306. FlightMode: isRadioOffMode(target),
  307. RadioOff: isRadioOffMode(target),
  308. }, err
  309. }
  310. if !enabled && !isRadioOffMode(current) {
  311. state.preFlightMode = nil
  312. }
  313. manager.updateSnapshotMode(id, state, current)
  314. manager.setResult(id, state, nil, nil)
  315. return FlightResult{
  316. PreviousMode: previous,
  317. CurrentMode: current,
  318. Changed: changed,
  319. FlightMode: isRadioOffMode(current),
  320. RadioOff: isRadioOffMode(current),
  321. }, nil
  322. }
  323. func (manager *Manager) readOperatingMode(
  324. ctx context.Context,
  325. client modem.Client,
  326. ) (int, error) {
  327. response, err := manager.command(ctx, client, "AT+CFUN?")
  328. if err != nil {
  329. return 0, err
  330. }
  331. mode, ok := parseCFUN(response)
  332. if !ok {
  333. return 0, errors.New("modem did not return a valid +CFUN value")
  334. }
  335. return mode, nil
  336. }
  337. func (manager *Manager) updateSnapshotMode(
  338. id string,
  339. state *managedDevice,
  340. mode int,
  341. ) {
  342. manager.mu.Lock()
  343. defer manager.mu.Unlock()
  344. if manager.devices[id] != state || state.snapshot == nil {
  345. return
  346. }
  347. state.snapshot.OperatingMode = mode
  348. state.snapshot.ModeKnown = true
  349. state.snapshot.FlightMode = isRadioOffMode(mode)
  350. state.snapshot.RadioOff = state.snapshot.FlightMode
  351. }