Files
VoCat/internal/server/esim_api.go
T
MengMengCode 8a260e86f1 Implement automatic task management with CRUD operations and UI integration
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks.
- Created `automatic_tasks_test.go` to validate task claiming and deletion behavior.
- Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution.
- Integrated device and eSIM profile selection for task configuration.
- Implemented automatic task scheduling and retry logic in the backend.
2026-08-10 22:15:54 +08:00

520 lines
17 KiB
Go

package server
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"vocat/internal/device"
"vocat/internal/store"
)
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, configuredIDs ...string) bool {
configuredID := physicalID
if len(configuredIDs) > 0 && strings.TrimSpace(configuredIDs[0]) != "" {
configuredID = strings.TrimSpace(configuredIDs[0])
}
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, configuredID, 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"`
AIDHexCamel string `json:"aidHex"`
}
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
}
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, 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, configuredID string, 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"`
AIDHexCamel string `json:"aidHex"`
}
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
}
// Profile operations run with RF disabled. The eUICC remains accessible in
// CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes
// back after the mandatory modem reset.
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
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{})
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, aidHex); err != nil {
s.writeDeviceError(w, err)
return
}
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
return
}
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
}); err != nil {
s.writeStoreError(w, err)
return
}
config, err := s.store.Device(r.Context(), configuredID)
if err != nil {
s.writeStoreError(w, err)
return
}
config.VoWiFiEnabled = true
config.NetworkEnabled = false
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
s.writeStoreError(w, err)
return
}
if s.vowifi != nil {
state, stateErr := s.vowifi.State(configuredID)
switch {
case stateErr == nil && state.Enabled:
_, err = s.vowifi.RequestReconnect(configuredID)
default:
_, err = s.vowifi.RequestEnabled(configuredID, true)
}
if err != nil {
s.logger.Warn("profile switched in safe airplane mode but VoWiFi start was not queued", "device_id", configuredID, "iccid", iccid, "error", err)
}
}
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"`
AIDHexCamel string `json:"aidHex"`
}
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
}
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, 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)
}