package server import ( "errors" "fmt" "net/http" "strings" "time" "vocat/internal/device" ) func esimUnavailable(w http.ResponseWriter) { writeError(w, http.StatusNotImplemented, "esim_operation_unavailable", "This specific eSIM operation is not implemented.") } // handleESIM routes every /devices/{id}/esim* path. func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool) bool { if len(rest) == 0 || (len(rest) == 1 && strings.TrimSpace(rest[0]) == "") { if !requireMethod(w, r, http.MethodGet) { return true } s.writeEsimOverview(w, r, physicalID, physicalPresent) return true } switch rest[0] { case "profiles": if len(rest) == 1 { if !requireMethod(w, r, http.MethodGet) { return true } s.writeEsimGroups(w, r, physicalID, physicalPresent) return true } if len(rest) == 2 && r.Method == http.MethodDelete { s.handleEsimDelete(w, r, physicalID, physicalPresent, rest[1]) return true } if len(rest) == 2 && r.Method == http.MethodPatch { s.handleEsimRename(w, r, physicalID, physicalPresent, rest[1]) return true } esimUnavailable(w) return true case "notifications": if len(rest) == 1 { if !requireMethod(w, r, http.MethodGet) { return true } // No LPA download backend, so there are never pending notifications. writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []any{}}}) return true } // notifications/{id}/actions/retry esimUnavailable(w) return true case "actions": if len(rest) == 2 && rest[1] == "switch" { if !requireMethod(w, r, http.MethodPost) { return true } s.handleEsimSwitch(w, r, physicalID, physicalPresent) return true } if len(rest) == 2 && rest[1] == "disable" { if !requireMethod(w, r, http.MethodPost) { return true } s.handleEsimDisable(w, r, physicalID, physicalPresent) return true } if len(rest) == 2 && rest[1] == "download" { if !requireMethod(w, r, http.MethodGet) { return true } s.handleEsimDownload(w, r, physicalID, physicalPresent) return true } // Any other provisioning action is not implemented. esimUnavailable(w) return true default: return false } } // esimInfo loads the eUICC profile list. The string result is "ok" (use info), // "empty" (no usable eUICC — render the empty state), or "error" (an error // response has already been written). func (s *Server) esimInfo(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) (string, []device.EsimInventoryEntry) { if s.devices == nil || !physicalPresent { return "empty", nil } info, err := s.devices.ESIMInventory(r.Context(), physicalID) if err != nil { if errors.Is(err, device.ErrNoEUICC) { return "empty", nil } s.writeDeviceError(w, err) return "error", nil } return "ok", info } // writeEsimOverview returns { chipInfo, profiles } for the eSIM tab. func (s *Server) writeEsimOverview(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { status, info := s.esimInfo(w, r, physicalID, physicalPresent) switch status { case "error": return case "empty": writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"chipInfo": nil, "profiles": []any{}}}) return } chipInfo := esimInventoryChipInfo(info) groups := esimInventoryGroups(info) writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "chipInfo": chipInfo, "profiles": groups, }, }) } func esimInventoryChipInfo(entries []device.EsimInventoryEntry) map[string]any { eids := make([]any, 0, len(entries)) firmware := "" for _, entry := range entries { chip := entry.Chip eid := map[string]any{"eid": chip.EID, "aid": chip.AID} if chip.HasFreeNvram { eid["freeNvramBytes"] = chip.FreeNvramBytes eid["freeNvram"] = fmt.Sprintf("%.2f KB", float64(chip.FreeNvramBytes)/1024) } if chip.Manufacturer != "" { eid["manufacturer"] = chip.Manufacturer } if len(chip.Certificates) > 0 { eid["certificates"] = chip.Certificates } if len(chip.TrustedCIs) > 0 { eid["trustedCiKeyIds"] = chip.TrustedCIs } if chip.DefaultSmdpAddress != "" { eid["defaultSmdpAddress"] = chip.DefaultSmdpAddress } if chip.RootDsAddress != "" { eid["rootDsAddress"] = chip.RootDsAddress } if chip.SAS != "" { eid["sasAccreditationNumber"] = chip.SAS } eids = append(eids, eid) if firmware == "" { firmware = chip.FirmwareVer } } result := map[string]any{"eids": eids} if firmware != "" { result["firmware"] = firmware } return result } func esimInventoryGroups(entries []device.EsimInventoryEntry) []map[string]any { groups := make([]map[string]any, 0, len(entries)) for _, entry := range entries { groups = append(groups, esimGroups(entry.Info)...) } return groups } // esimChipInfo reads the eUICC chip header (EID, firmware, free NVRAM, // manufacturer, CI certificates, SM-DP+/Root SM-DS addresses, SAS, info source) // for the eSIM tab. On any read failure it returns a sparse object so the // profile list still renders. func (s *Server) esimChipInfo(r *http.Request, physicalID string) map[string]any { chip, err := s.devices.ESIMChipInfo(r.Context(), physicalID) if err != nil || chip == nil { return map[string]any{} } eid := map[string]any{ "eid": chip.EID, "aid": chip.AID, } if chip.HasFreeNvram { eid["freeNvramBytes"] = chip.FreeNvramBytes eid["freeNvram"] = fmt.Sprintf("%.2f KB", float64(chip.FreeNvramBytes)/1024) } if chip.Manufacturer != "" { eid["manufacturer"] = chip.Manufacturer } if len(chip.Certificates) > 0 { eid["certificates"] = chip.Certificates } if len(chip.TrustedCIs) > 0 { eid["trustedCiKeyIds"] = chip.TrustedCIs } if chip.DefaultSmdpAddress != "" { eid["defaultSmdpAddress"] = chip.DefaultSmdpAddress } if chip.RootDsAddress != "" { eid["rootDsAddress"] = chip.RootDsAddress } if chip.SAS != "" { eid["sasAccreditationNumber"] = chip.SAS } chipMap := map[string]any{ "eids": []any{eid}, } if chip.FirmwareVer != "" { chipMap["firmware"] = chip.FirmwareVer } return chipMap } // writeEsimGroups returns just the profile groups for the /esim/profiles call. func (s *Server) writeEsimGroups(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { status, info := s.esimInfo(w, r, physicalID, physicalPresent) switch status { case "error": return case "empty": writeJSON(w, http.StatusOK, map[string]any{"data": []any{}}) return } groups := esimInventoryGroups(info) writeJSON(w, http.StatusOK, map[string]any{"data": groups}) } // GetProfilesInfo does not include an EID on every eUICC implementation. The // EC20 hosts one physical eUICC, so associate the separately-read chip identity // with that sole profile group. Without this, the SPA cannot match the group to // its manufacturer/certificate/production metadata even though it was read. func attachSingleEUICCIdentity(groups []map[string]any, chipInfo map[string]any) { if len(groups) != 1 { return } eids, ok := chipInfo["eids"].([]any) if !ok || len(eids) != 1 { return } identity, ok := eids[0].(map[string]any) if !ok { return } groupEID, _ := groups[0]["eid"].(string) chipEID, _ := identity["eid"].(string) if strings.TrimSpace(groupEID) == "" && strings.TrimSpace(chipEID) != "" { groups[0]["eid"] = strings.TrimSpace(chipEID) } groupAID, _ := groups[0]["aidHex"].(string) chipAID, _ := identity["aid"].(string) if strings.TrimSpace(groupAID) == "" && strings.TrimSpace(chipAID) != "" { groups[0]["aidHex"] = strings.TrimSpace(chipAID) } } // esimGroups flattens the eUICC profile list into the SPA's per-eUICC groups // (the EC20 hosts a single eUICC, so this is normally one group). func esimGroups(info device.EsimInfo) []map[string]any { profiles := make([]map[string]any, 0, len(info.Profiles)) for _, p := range info.Profiles { profiles = append(profiles, map[string]any{ "iccid": p.ICCID, "name": firstNonEmpty(p.Nickname, p.Name), "serviceProviderName": p.ServiceProvider, "state": p.State, "stateText": p.StateText, "classText": p.Class, }) } return []map[string]any{ { "eid": info.EID, "aidHex": info.AID, "profiles": profiles, }, } } func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, iccid string) { if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") return } if !physicalPresent { writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") return } iccid = strings.TrimSpace(iccid) if iccid == "" { writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") return } var request struct { Name string `json:"name"` AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; ICCID addresses the profile } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } nickname := strings.TrimSpace(request.Name) if nickname == "" { writeError(w, http.StatusBadRequest, "invalid_request", "profile nickname is required") return } if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, request.AIDHex); err != nil { s.writeDeviceError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "renamed", "iccid": iccid, "name": nickname}}) } // handleEsimSwitch enables one already-installed profile by ICCID (切卡). The // eUICC EnableProfile command needs no authentication key. func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") return } if !physicalPresent { writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") return } var request struct { ICCID string `json:"iccid"` AIDHex string `json:"aid_hex"` // accepted for contract compatibility; switching keys off iccid } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } iccid := strings.TrimSpace(request.ICCID) if iccid == "" { writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") return } // A confirmed profile switch includes the EC20 reset and a live ICCID read, // which normally takes longer than the server's ordinary response deadline. controller := http.NewResponseController(w) _ = controller.SetWriteDeadline(time.Time{}) if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil { s.writeDeviceError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}}) } func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") return } if !physicalPresent { writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") return } var request struct { ICCID string `json:"iccid"` AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; disabling keys off ICCID } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } iccid := strings.TrimSpace(request.ICCID) if iccid == "" { writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") return } if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil { s.writeDeviceError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "disabled", "iccid": iccid, "recovering": true}}) } func (s *Server) handleEsimDelete(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, iccid string) { if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") return } if !physicalPresent { writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") return } iccid = strings.TrimSpace(iccid) if iccid == "" { writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") return } result, err := s.devices.ESIMDeleteProfile(r.Context(), physicalID, iccid, r.URL.Query().Get("aid_hex")) if err != nil { s.writeDeviceError(w, err) return } data := map[string]any{ "status": "deleted", "iccid": iccid, "spaceDelta": map[string]any{"direction": "reclaimed", "bytes": result.SpaceDelta}, } if result.Warning != "" { data["warning"] = result.Warning } writeJSON(w, http.StatusOK, map[string]any{"data": data}) } // handleEsimDownload streams one eSIM profile download (写卡) as Server-Sent // Events. The SPA drives it with GET + query params (smdp/matching_id/ // confirmation_code/aid_hex/imei) and reads `data: {step,msg,pct,...}` lines. // The event field names (step/msg/pct/code/space_delta/warning) match the // reference contract byte-for-byte, so the frontend needs no changes. func (s *Server) handleEsimDownload(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") return } if !physicalPresent { writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") return } query := r.URL.Query() params := device.EsimDownloadParams{ SMDP: query.Get("smdp"), MatchingID: query.Get("matching_id"), ConfirmationCode: query.Get("confirmation_code"), AIDHex: query.Get("aid_hex"), IMEI: query.Get("imei"), } if strings.TrimSpace(params.SMDP) == "" { writeError(w, http.StatusBadRequest, "invalid_request", "smdp 为必填项") return } controller := beginSSE(w) emit := func(payload map[string]any) { // A failed write means the client went away; r.Context() is then already // cancelled, so the device layer stops the download on its own. _ = writeSSEEvent(w, controller, "progress", payload) } result, err := s.devices.ESIMDownloadProfile(r.Context(), physicalID, params, func(p device.EsimProgress) { emit(map[string]any{"step": p.Step, "msg": p.Msg, "pct": p.Pct}) }) if err != nil { emit(map[string]any{ "step": "error", "msg": "下载失败: " + err.Error(), "pct": -1, "code": device.ESIMDownloadErrorCode(err), }) return } done := map[string]any{ "step": "done", "msg": "Profile 下载完成", "pct": 100, "space_delta": map[string]any{"direction": "consumed", "bytes": result.SpaceDelta}, } if result.Warning != "" { done["warning"] = result.Warning } emit(done) }