Add mock GPS simulator and configurable location sources

This commit is contained in:
2026-03-24 14:24:53 +08:00
parent 0295893b56
commit 2cf0bb76b4
16 changed files with 2575 additions and 122 deletions

View File

@@ -1,153 +1,227 @@
export interface LocationUpdate {
latitude: number
longitude: number
accuracy?: number
speed?: number
import { DEFAULT_MOCK_LOCATION_BRIDGE_URL, MockLocationBridge } from './mockLocationBridge'
import { MockLocationSource } from './mockLocationSource'
import { RealLocationSource } from './realLocationSource'
import { type LocationSample, type LocationSourceCallbacks, type LocationSourceMode } from './locationSource'
export interface LocationUpdate extends LocationSample {}
export interface LocationControllerDebugState {
sourceMode: LocationSourceMode
sourceModeText: string
listening: boolean
mockBridgeConnected: boolean
mockBridgeStatusText: string
mockBridgeUrlText: string
mockCoordText: string
mockSpeedText: string
}
export interface LocationControllerCallbacks {
onLocation: (update: LocationUpdate) => void
onStatus: (message: string) => void
onError: (message: string) => void
onDebugStateChange?: (state: LocationControllerDebugState) => void
}
function hasLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
const authSettings = settings as Record<string, boolean | undefined>
return !!authSettings['scope.userLocation']
function formatSourceModeText(mode: LocationSourceMode): string {
return mode === 'mock' ? '模拟定位' : '真实定位'
}
function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
const authSettings = settings as Record<string, boolean | undefined>
return !!authSettings['scope.userLocationBackground']
function formatMockCoordText(sample: LocationSample | null): string {
if (!sample) {
return '--'
}
return `${sample.latitude.toFixed(6)}, ${sample.longitude.toFixed(6)}`
}
function formatMockSpeedText(sample: LocationSample | null): string {
if (!sample || !Number.isFinite(sample.speed)) {
return '--'
}
return `${(Number(sample.speed) * 3.6).toFixed(1)} km/h`
}
function normalizeMockBridgeUrl(rawUrl: string): string {
const trimmed = rawUrl.trim()
if (!trimmed) {
return DEFAULT_MOCK_LOCATION_BRIDGE_URL
}
let normalized = trimmed
if (!/^wss?:\/\//i.test(normalized)) {
normalized = `ws://${normalized.replace(/^\/+/, '')}`
}
if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
normalized = normalized.replace(/\/+$/, '')
normalized = `${normalized}/mock-gps`
}
return normalized
}
export class LocationController {
callbacks: LocationControllerCallbacks
listening: boolean
boundLocationHandler: ((result: WechatMiniprogram.OnLocationChangeCallbackResult) => void) | null
realSource: RealLocationSource
mockSource: MockLocationSource
mockBridge: MockLocationBridge
sourceMode: LocationSourceMode
mockBridgeStatusText: string
mockBridgeUrl: string
constructor(callbacks: LocationControllerCallbacks) {
this.callbacks = callbacks
this.listening = false
this.boundLocationHandler = null
this.sourceMode = 'real'
this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
const sourceCallbacks: LocationSourceCallbacks = {
onLocation: (sample) => {
this.callbacks.onLocation(sample)
this.emitDebugState()
},
onStatus: (message) => {
this.callbacks.onStatus(message)
this.emitDebugState()
},
onError: (message) => {
this.callbacks.onError(message)
this.emitDebugState()
},
}
this.realSource = new RealLocationSource(sourceCallbacks)
this.mockSource = new MockLocationSource(sourceCallbacks)
this.mockBridge = new MockLocationBridge({
onOpen: () => {
this.mockBridgeStatusText = `已连接 (${this.mockBridge.url})`
this.callbacks.onStatus('模拟定位源已连接')
this.emitDebugState()
},
onClose: () => {
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
this.callbacks.onStatus('模拟定位源已断开')
this.emitDebugState()
},
onError: (message) => {
this.mockBridgeStatusText = `连接失败 (${this.mockBridge.url})`
this.callbacks.onError(`模拟定位源错误: ${message}`)
this.emitDebugState()
},
onSample: (sample) => {
this.mockSource.pushSample(sample)
this.emitDebugState()
},
})
}
get listening(): boolean {
return this.sourceMode === 'mock' ? this.mockSource.active : this.realSource.active
}
getDebugState(): LocationControllerDebugState {
return {
sourceMode: this.sourceMode,
sourceModeText: formatSourceModeText(this.sourceMode),
listening: this.listening,
mockBridgeConnected: this.mockBridge.connected,
mockBridgeStatusText: this.mockBridgeStatusText,
mockBridgeUrlText: this.mockBridgeUrl,
mockCoordText: formatMockCoordText(this.mockSource.lastSample),
mockSpeedText: formatMockSpeedText(this.mockSource.lastSample),
}
}
start(): void {
if (this.listening) {
this.callbacks.onStatus('后台持续定位进行中')
return
}
wx.getSetting({
success: (result) => {
const settings = result.authSetting || {}
if (hasBackgroundLocationPermission(settings)) {
this.startBackgroundLocation()
return
}
if (hasLocationPermission(settings)) {
this.requestBackgroundPermissionInSettings()
return
}
wx.authorize({
scope: 'scope.userLocation',
success: () => {
this.requestBackgroundPermissionInSettings()
},
fail: () => {
this.requestBackgroundPermissionInSettings()
},
})
},
fail: (error) => {
const message = error && error.errMsg ? error.errMsg : 'getSetting 失败'
this.callbacks.onError(`GPS授权检查失败: ${message}`)
},
})
}
requestBackgroundPermissionInSettings(): void {
this.callbacks.onStatus('请在授权面板开启后台定位')
wx.openSetting({
success: (result) => {
const settings = result.authSetting || {}
if (hasBackgroundLocationPermission(settings)) {
this.startBackgroundLocation()
return
}
this.callbacks.onError('GPS启动失败: 未授予后台定位权限')
},
fail: (error) => {
const message = error && error.errMsg ? error.errMsg : 'openSetting 失败'
this.callbacks.onError(`GPS启动失败: ${message}`)
},
})
}
startBackgroundLocation(): void {
wx.startLocationUpdateBackground({
type: 'wgs84',
success: () => {
this.bindLocationListener()
this.listening = true
this.callbacks.onStatus('后台持续定位已启动')
},
fail: (error) => {
const message = error && error.errMsg ? error.errMsg : 'startLocationUpdateBackground 失败'
this.callbacks.onError(`GPS启动失败: ${message}`)
},
})
this.getActiveSource().start()
this.emitDebugState()
}
stop(): void {
if (!this.listening) {
this.callbacks.onStatus('后台持续定位未启动')
return
}
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
wx.offLocationChange(this.boundLocationHandler)
}
this.boundLocationHandler = null
wx.stopLocationUpdate({
complete: () => {
this.listening = false
this.callbacks.onStatus('后台持续定位已停止')
},
})
this.getActiveSource().stop()
this.emitDebugState()
}
destroy(): void {
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
wx.offLocationChange(this.boundLocationHandler)
}
this.boundLocationHandler = null
if (this.listening) {
wx.stopLocationUpdate({ complete: () => {} })
this.listening = false
}
this.realSource.destroy()
this.mockSource.destroy()
this.mockBridge.destroy()
this.emitDebugState()
}
bindLocationListener(): void {
if (this.boundLocationHandler) {
setSourceMode(mode: LocationSourceMode): void {
if (this.sourceMode === mode) {
this.callbacks.onStatus(`${formatSourceModeText(mode)}已启用`)
this.emitDebugState()
return
}
this.boundLocationHandler = (result) => {
this.callbacks.onLocation({
latitude: result.latitude,
longitude: result.longitude,
accuracy: result.accuracy,
speed: result.speed,
})
const wasListening = this.listening
if (wasListening) {
this.getActiveSource().stop()
}
this.sourceMode = mode
if (wasListening) {
this.getActiveSource().start()
} else {
this.callbacks.onStatus(`已切换到${formatSourceModeText(mode)}`)
}
wx.onLocationChange(this.boundLocationHandler)
this.emitDebugState()
}
setMockBridgeUrl(url: string): void {
this.mockBridgeUrl = normalizeMockBridgeUrl(url)
if (this.mockBridge.connected || this.mockBridge.connecting) {
this.mockBridgeStatusText = `已设置新地址,重连生效 (${this.mockBridgeUrl})`
this.callbacks.onStatus('模拟定位源地址已更新,重连后生效')
} else {
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
this.callbacks.onStatus('模拟定位源地址已更新')
}
this.emitDebugState()
}
connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
if (this.mockBridge.connected || this.mockBridge.connecting) {
this.callbacks.onStatus('模拟定位源已连接')
this.emitDebugState()
return
}
const targetUrl = normalizeMockBridgeUrl(url === DEFAULT_MOCK_LOCATION_BRIDGE_URL ? this.mockBridgeUrl : url)
this.mockBridgeUrl = targetUrl
this.mockBridgeStatusText = `连接中 (${targetUrl})`
this.emitDebugState()
this.callbacks.onStatus('模拟定位源连接中')
this.mockBridge.connect(targetUrl)
}
disconnectMockBridge(): void {
if (!this.mockBridge.connected && !this.mockBridge.connecting) {
this.callbacks.onStatus('模拟定位源未连接')
this.emitDebugState()
return
}
this.mockBridge.disconnect()
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
this.callbacks.onStatus('模拟定位源已断开')
this.emitDebugState()
}
getActiveSource(): RealLocationSource | MockLocationSource {
return this.sourceMode === 'mock' ? this.mockSource : this.realSource
}
emitDebugState(): void {
if (this.callbacks.onDebugStateChange) {
this.callbacks.onDebugStateChange(this.getDebugState())
}
}
}

View File

@@ -0,0 +1,25 @@
export type LocationSourceMode = 'real' | 'mock'
export interface LocationSample {
latitude: number
longitude: number
accuracy?: number
speed?: number | null
headingDeg?: number | null
timestamp: number
sourceMode: LocationSourceMode
}
export interface LocationSourceCallbacks {
onLocation: (sample: LocationSample) => void
onStatus: (message: string) => void
onError: (message: string) => void
}
export interface LocationSource {
readonly mode: LocationSourceMode
readonly active: boolean
start(): void
stop(): void
destroy(): void
}

View File

@@ -0,0 +1,147 @@
import { type LocationSample } from './locationSource'
export const DEFAULT_MOCK_LOCATION_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-gps'
export interface MockLocationBridgeCallbacks {
onOpen: () => void
onClose: (message: string) => void
onError: (message: string) => void
onSample: (sample: LocationSample) => void
}
type RawMockGpsMessage = {
type?: string
timestamp?: number
lat?: number
lon?: number
accuracyMeters?: number
speedMps?: number
headingDeg?: number
}
function safeParseMessage(data: string): RawMockGpsMessage | null {
try {
return JSON.parse(data) as RawMockGpsMessage
} catch (_error) {
return null
}
}
function toLocationSample(message: RawMockGpsMessage): LocationSample | null {
if (message.type !== 'mock_gps') {
return null
}
if (!Number.isFinite(message.lat) || !Number.isFinite(message.lon)) {
return null
}
return {
latitude: Number(message.lat),
longitude: Number(message.lon),
accuracy: Number.isFinite(message.accuracyMeters) ? Number(message.accuracyMeters) : undefined,
speed: Number.isFinite(message.speedMps) ? Number(message.speedMps) : null,
headingDeg: Number.isFinite(message.headingDeg) ? Number(message.headingDeg) : null,
timestamp: Number.isFinite(message.timestamp) ? Number(message.timestamp) : Date.now(),
sourceMode: 'mock',
}
}
export class MockLocationBridge {
callbacks: MockLocationBridgeCallbacks
socketTask: WechatMiniprogram.SocketTask | null
connected: boolean
connecting: boolean
url: string
constructor(callbacks: MockLocationBridgeCallbacks) {
this.callbacks = callbacks
this.socketTask = null
this.connected = false
this.connecting = false
this.url = DEFAULT_MOCK_LOCATION_BRIDGE_URL
}
connect(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
if (this.connected || this.connecting) {
return
}
this.url = url
this.connecting = true
try {
const socketTask = wx.connectSocket({
url,
})
this.socketTask = socketTask
socketTask.onOpen(() => {
this.connected = true
this.connecting = false
this.callbacks.onOpen()
})
socketTask.onClose((result) => {
const reason = result && result.reason ? result.reason : '模拟源连接已关闭'
this.connected = false
this.connecting = false
this.socketTask = null
this.callbacks.onClose(reason)
})
socketTask.onError((result) => {
const message = result && result.errMsg ? result.errMsg : '模拟源连接失败'
this.connected = false
this.connecting = false
this.socketTask = null
this.callbacks.onError(message)
})
socketTask.onMessage((result) => {
if (!result || typeof result.data !== 'string') {
return
}
const parsed = safeParseMessage(result.data)
if (!parsed) {
this.callbacks.onError('模拟源消息不是合法 JSON')
return
}
const sample = toLocationSample(parsed)
if (!sample) {
return
}
this.callbacks.onSample(sample)
})
} catch (error) {
this.connected = false
this.connecting = false
this.socketTask = null
const message = error && (error as Error).message ? (error as Error).message : '模拟源连接创建失败'
this.callbacks.onError(message)
}
}
disconnect(): void {
if (!this.socketTask) {
if (this.connected || this.connecting) {
this.connected = false
this.connecting = false
}
return
}
const socketTask = this.socketTask
this.socketTask = null
this.connected = false
this.connecting = false
socketTask.close({})
}
destroy(): void {
this.disconnect()
}
}

View File

@@ -0,0 +1,51 @@
import { type LocationSample, type LocationSource, type LocationSourceCallbacks } from './locationSource'
export class MockLocationSource implements LocationSource {
callbacks: LocationSourceCallbacks
active: boolean
lastSample: LocationSample | null
constructor(callbacks: LocationSourceCallbacks) {
this.callbacks = callbacks
this.active = false
this.lastSample = null
}
get mode(): 'mock' {
return 'mock'
}
start(): void {
if (this.active) {
this.callbacks.onStatus('模拟定位进行中')
return
}
this.active = true
this.callbacks.onStatus('模拟定位已启动,等待外部输入')
}
stop(): void {
if (!this.active) {
this.callbacks.onStatus('模拟定位未启动')
return
}
this.active = false
this.callbacks.onStatus('模拟定位已停止')
}
destroy(): void {
this.active = false
this.lastSample = null
}
pushSample(sample: LocationSample): void {
this.lastSample = sample
if (!this.active) {
return
}
this.callbacks.onLocation(sample)
}
}

View File

@@ -0,0 +1,147 @@
import { type LocationSource, type LocationSourceCallbacks } from './locationSource'
function hasLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
const authSettings = settings as Record<string, boolean | undefined>
return !!authSettings['scope.userLocation']
}
function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
const authSettings = settings as Record<string, boolean | undefined>
return !!authSettings['scope.userLocationBackground']
}
export class RealLocationSource implements LocationSource {
callbacks: LocationSourceCallbacks
active: boolean
boundLocationHandler: ((result: WechatMiniprogram.OnLocationChangeCallbackResult) => void) | null
constructor(callbacks: LocationSourceCallbacks) {
this.callbacks = callbacks
this.active = false
this.boundLocationHandler = null
}
get mode(): 'real' {
return 'real'
}
start(): void {
if (this.active) {
this.callbacks.onStatus('后台持续定位进行中')
return
}
wx.getSetting({
success: (result) => {
const settings = result.authSetting || {}
if (hasBackgroundLocationPermission(settings)) {
this.startBackgroundLocation()
return
}
if (hasLocationPermission(settings)) {
this.requestBackgroundPermissionInSettings()
return
}
wx.authorize({
scope: 'scope.userLocation',
success: () => {
this.requestBackgroundPermissionInSettings()
},
fail: () => {
this.requestBackgroundPermissionInSettings()
},
})
},
fail: (error) => {
const message = error && error.errMsg ? error.errMsg : 'getSetting 失败'
this.callbacks.onError(`GPS授权检查失败: ${message}`)
},
})
}
stop(): void {
if (!this.active) {
this.callbacks.onStatus('后台持续定位未启动')
return
}
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
wx.offLocationChange(this.boundLocationHandler)
}
this.boundLocationHandler = null
wx.stopLocationUpdate({
complete: () => {
this.active = false
this.callbacks.onStatus('后台持续定位已停止')
},
})
}
destroy(): void {
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
wx.offLocationChange(this.boundLocationHandler)
}
this.boundLocationHandler = null
if (this.active) {
wx.stopLocationUpdate({ complete: () => {} })
this.active = false
}
}
requestBackgroundPermissionInSettings(): void {
this.callbacks.onStatus('请在授权面板开启后台定位')
wx.openSetting({
success: (result) => {
const settings = result.authSetting || {}
if (hasBackgroundLocationPermission(settings)) {
this.startBackgroundLocation()
return
}
this.callbacks.onError('GPS启动失败: 未授予后台定位权限')
},
fail: (error) => {
const message = error && error.errMsg ? error.errMsg : 'openSetting 失败'
this.callbacks.onError(`GPS启动失败: ${message}`)
},
})
}
startBackgroundLocation(): void {
wx.startLocationUpdateBackground({
type: 'wgs84',
success: () => {
this.bindLocationListener()
this.active = true
this.callbacks.onStatus('后台持续定位已启动')
},
fail: (error) => {
const message = error && error.errMsg ? error.errMsg : 'startLocationUpdateBackground 失败'
this.callbacks.onError(`GPS启动失败: ${message}`)
},
})
}
bindLocationListener(): void {
if (this.boundLocationHandler) {
return
}
this.boundLocationHandler = (result) => {
this.callbacks.onLocation({
latitude: result.latitude,
longitude: result.longitude,
accuracy: result.accuracy,
speed: result.speed,
timestamp: Date.now(),
sourceMode: 'real',
})
}
wx.onLocationChange(this.boundLocationHandler)
}
}