mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
- 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.
520 lines
17 KiB
Go
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)
|
|
}
|