package server import ( "context" "errors" "net/http" "strconv" "strings" "time" "vocat/internal/modem" "vocat/internal/store" "vocat/internal/vowifi" ) const maxCallDuration = 10 * time.Minute func (s *Server) handleCalls(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool { if !requireMethod(w, r, http.MethodGet) { return true } transport := s.callTransport(config.ID) if transport == "vowifi" { controller, ok := s.vowifi.(VoWiFiCallController) if !ok { writeError(w, http.StatusNotImplemented, "vowifi_voice_unavailable", "the active VoWiFi IMS session does not expose voice-call signalling") return true } calls, err := controller.Calls(config.ID) if err != nil { writeError(w, http.StatusServiceUnavailable, "vowifi_call_failed", err.Error()) return true } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ "device_id": config.ID, "transport": transport, "calls": calls, }}) return true } response, err := s.devices.ExecuteAT(r.Context(), physicalID, "AT+CLCC") if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "device_id": config.ID, "transport": transport, "calls": parseCLCC(response), "raw": response.Text(), }, }) return true } func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config store.Device, physicalID, action string) bool { if !requireMethod(w, r, http.MethodPost) { return true } command := "" duration := time.Duration(0) number := "" callID := "" switch action { case "dial": var request struct { Number string `json:"number"` DurationSeconds int `json:"duration_seconds"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } number = strings.TrimSpace(request.Number) if !validDialNumber(number) { writeError(w, http.StatusBadRequest, "invalid_number", "phone number is invalid") return true } duration = time.Duration(request.DurationSeconds) * time.Second if duration < 0 || duration > maxCallDuration { writeError(w, http.StatusBadRequest, "invalid_duration", "duration_seconds must be 0 (no automatic hang-up) or between 1 and 600") return true } command = "ATD" + number + ";" case "answer": var request struct { CallID string `json:"call_id"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } callID = strings.TrimSpace(request.CallID) command = "ATA" case "hangup": var request struct { CallID string `json:"call_id"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } callID = strings.TrimSpace(request.CallID) command = "ATH" default: writeError(w, http.StatusNotFound, "not_found", "call action not found") return true } transport := s.callTransport(config.ID) if transport == "vowifi" { controller, ok := s.vowifi.(VoWiFiCallController) if !ok { writeError(w, http.StatusNotImplemented, "vowifi_voice_unavailable", "the active VoWiFi IMS session does not expose voice-call signalling") return true } var result any var err error switch action { case "dial": result, err = controller.DialCall(r.Context(), config.ID, number) case "answer": callID, err = resolveVoWiFiCallID(controller, config.ID, callID, "ringing") if err == nil { result, err = controller.AnswerCall(r.Context(), config.ID, callID) } case "hangup": callID, err = resolveVoWiFiCallID(controller, config.ID, callID, "") if err == nil { err = controller.HangupCall(r.Context(), config.ID, callID) } } if err != nil { writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error()) return true } if action == "dial" { if call, ok := result.(vowifi.Call); ok { callID = call.ID } if duration > 0 { go s.hangupVoWiFiAfter(config.ID, callID, duration) } } s.recordAudit(r.Context(), "admin", "call."+action, "device", config.ID, "success", transport) writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{ "accepted": true, "action": action, "number": number, "call_id": callID, "duration_seconds": int(duration / time.Second), "transport": transport, "call": result, }}) return true } operationContext, cancel := context.WithTimeout(r.Context(), 20*time.Second) response, err := s.devices.ExecuteAT(operationContext, physicalID, command) cancel() if err != nil { s.writeDeviceError(w, err) return true } if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") { writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action") return true } if action == "dial" { if duration > 0 { go s.hangupAfter(config.ID, physicalID, duration) } } s.recordAudit(r.Context(), "admin", "call."+action, "device", config.ID, "success", transport) writeJSON(w, http.StatusAccepted, map[string]any{ "data": map[string]any{ "accepted": true, "action": action, "number": number, "duration_seconds": int(duration / time.Second), "transport": transport, }, }) return true } func resolveVoWiFiCallID(controller VoWiFiCallController, deviceID, id, requiredState string) (string, error) { if id != "" { return id, nil } calls, err := controller.Calls(deviceID) if err != nil { return "", err } for _, call := range calls { if call.State != "ended" && call.State != "failed" && (requiredState == "" || call.State == requiredState) { return call.ID, nil } } return "", errors.New("no matching active call") } func (s *Server) hangupVoWiFiAfter(deviceID, callID string, duration time.Duration) { timer := time.NewTimer(duration) defer timer.Stop() <-timer.C controller, ok := s.vowifi.(VoWiFiCallController) if !ok { return } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := controller.HangupCall(ctx, deviceID, callID); err != nil { s.logger.Warn("automatic VoWiFi call hangup failed", "device_id", deviceID, "call_id", callID, "error", err) } } func (s *Server) callTransport(deviceID string) string { if s.vowifi != nil { // Enabled is only the desired card policy. Calls can use IMS only after // registration has actually completed; otherwise keep using the modem's // circuit-switched call path instead of routing into an unavailable IMS // session. if state, err := s.vowifi.State(deviceID); err == nil && state.IMSReady { return "vowifi" } } return "cellular" } func (s *Server) hangupAfter(deviceID, physicalID string, duration time.Duration) { timer := time.NewTimer(duration) defer timer.Stop() <-timer.C ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if _, err := s.devices.ExecuteAT(ctx, physicalID, "ATH"); err != nil { s.logger.Warn("automatic call hangup failed", "device_id", deviceID, "error", err) } } func validDialNumber(value string) bool { if len(value) < 2 || len(value) > 32 { return false } for index, character := range value { if character >= '0' && character <= '9' || (index == 0 && character == '+') || character == '*' || character == '#' { continue } return false } return true } func parseCLCC(response modem.Response) []map[string]any { result := make([]map[string]any, 0) for _, line := range response.Lines { line = strings.TrimSpace(line) if !strings.HasPrefix(strings.ToUpper(line), "+CLCC:") { continue } fields := strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "+CLCC:")), ",") if len(fields) < 5 { continue } integer := func(index int) int { value, _ := strconv.Atoi(strings.TrimSpace(fields[index])) return value } call := map[string]any{ "index": integer(0), "direction": integer(1), "state": integer(2), "mode": integer(3), "multiparty": integer(4), "raw": line, } if len(fields) > 5 { call["number"] = strings.Trim(strings.TrimSpace(fields[5]), `"`) } result = append(result, call) } return result }