
做充电桩App配网功能那阵子我踩了不少坑。最典型的一个场景桩已经装好配电箱里网线没拉用户要求“手机连上去配个网就能用”。市面上多数充电桩内置WiFi模块出厂处于AP热点模式手机必须先去连它的热点再通过局域网把家里/商铺的路由器账号密码发过去桩才能切换成STA模式上线。这个流程听起来简单真正落地写Android/Kotlin代码的时候涉及权限适配、热点切换、指令协议、超时重试、配网结果验证等一系列问题任何一个环节处理不好用户的App就卡在“配网中”转圈。这篇内容我把完整的局域网AP配网流程、报文指令格式、Kotlin核心实现以及实测里的那些坑整理出来给正在做充电桩或者类似IoT设备配网的Android开发者一个可以直接参考的实例说明。无论你用的是ESP8266、ESP32还是其他WiFi模组思路基本通用。1. 为什么充电桩配网首选局域网AP模式三种方案的实际对比入行早的开发者应该还记得IoT设备配网经历过好几个阶段早期拿SmartConfig/UDP广播一键配网后来手机扫二维码配网再后来加蓝牙辅助配网。充电桩这个品类比较特殊它的硬件形态、安装位置、使用场景决定了配网方案不能随便选。1.1 SmartConfig、BLE配网与AP配网的取舍一键配网SmartConfig的实现原理是手机向周围空气里以特定长度、间隔的UDP报文广播WiFi密码设备端WiFi模组处于混杂模式下抓取空中的报文解析出SSID和密码。听着很黑科技实际用起来很折磨人不同路由器、不同WiFi网卡对报文长度的响应不一样小米路由器、TP-Link、企业AP都有可能解不出密码如果现场有2.4G和5G双频合一报文从5G频段飞出去而设备只听2.4G那就直接白配。充电桩又常装在户外或地下室周围有其他无线信号干扰一键配网的成功率我实测过很多次稳定在70%到85%之间对C端用户来说这就是“十次有三五次配不上”口碑直接崩。蓝牙配网是目前体验最好的方案之一但前提是充电桩里加了BLE芯片或模组。很多低成本交流桩的板子上压根没有蓝牙只有一颗WiFi模块这时候蓝牙方案根本无从谈起。而且蓝牙配网需要在桩身边操作蓝牙信号受距离限制对车库里装在墙上的桩来说用户得凑得很近才能连上体验也不算优雅。AP配网也就是标题里说的局域网配网是这三种方案里最稳的手机直接连接充电桩发射的热点建立一条真实的局域网链路然后用Socket指令把路由器信息发给桩。链路是实实在在的不存在“空中抓包”这种概率事件配网成功率做到99%以上很轻松。缺点是流程重一点要切换WiFi、连接热点、等待网络稳定但对充电桩这种“安装好之后基本不动”的设备来说配网只发生在首次部署或换路由器的时候多几秒钟体验完全可以接受。1.2 局域网AP配网的总体架构配网涉及的两端是这样的桩端WiFi模块上电后默认进入SoftAP模式广播一个类似ChargePile_8A2F的SSIDIP地址固定为192.168.4.1这是ESP8266/ESP32的默认AP网关地址在某个TCP端口上监听配网指令比如6000。手机端Android App扫描识别出这个热点主动切换过去建立与192.168.4.1:6000的TCP连接发送查询指令、设置WiFi指令解析设备返回的结果。完整流程可以拆成这样App检查权限并打开WiFi扫描附近热点。用户选择或App自动识别充电桩热点一般有特殊的SSID前缀。手机断开当前网络连接桩热点。建立Socket连接发送“查询设备信息”指令确认链路正常。用户输入要接入的路由器SSID和密码App发送“设置WiFi信息”指令。桩接收成功并回复结果随后重启WiFi模块切换到STA模式去连路由器。App此时主动断开桩热点、回连原路由器并通过局域网发现或云端上报确认桩已上线。这套链路里最磨人的是第3步和第5步手机连热点、切回原网络在Android上不同版本差异极大指令发送则依赖桩端协议的稳定性。下面我把这两块分别展开。2. 充电桩WiFi模块的指令协议帧结构、命令字与编解码很多充电桩厂家的WiFi模组协议是在通讯模组原厂AT指令集之上再包一层私有协议。不管具体怎么定你在Android端和桩端通信时必须先和桩端固件团队把报文格式对齐。下面拿一套我实际用过的简洁二进制协议做例子它适合单片机做解析也适合Android端做编解码。2.1 帧结构定义报文固定由这几部分组成字段长度说明帧头11字节固定0xAA帧头21字节固定0x55数据区长度2字节大端指命令字载荷的总长度即1N命令字1字节0x01~0x05载荷N字节具体数据内容校验值2字节累加和校验覆盖从数据区长度到载荷结束的所有字节大端为什么用二进制协议而不是直接跑JSON桩端的WiFi模块主频通常只有几十到一百多MHz内存几百KB解析JSON不仅慢而且容易因为字符串转义、字段顺序问题出错二进制协议每个字节都有明确定义单片机逐字节处理即可出错率低。TCP传输本身是字节流长度字段可以帮接收方做粘包拆包。2.2 配网核心指令表命令字指令方向功能载荷说明应答载荷0x01App→桩查询设备信息空设备类型(2字节)、固件版本(4字节)、MAC(6字节)、是否已配网(1字节)0x02App→桩进入配网模式空结果(1字节)0成功0x03App→桩设置WiFi信息SSID长度(2)SSID密码长度(2)密码结果(1字节)0接收成功0x04桩→App配网结果上报状态(1字节)0成功1连接失败2密码错误空0x05App→桩查询配网状态空状态(1字节)这里补充说明几个细节。SSID和密码都用UTF-8编码长度字段是大端两字节。中文SSID在UTF-8下会占到6字节长度字段一定要传字节数而不是字符个数否则桩端解析会错位。我见过好几个项目在英文SSID下正常一遇到“张三家的充电桩”这种中文SSID就解析出乱码排查到最后都是长度算错了。密码中的空格、引号、反斜杠不需要做任何转义直接以原始字节塞进载荷。很多从服务端下发WiFi密码的场景里开发人员习惯先给字符串做一层URL Encode再下发这是完全错误的。校验和算法我用的是比较简单直接的累加和从“数据区长度”的第一个字节开始加一直加到载荷最后一个字节累加值取低16位。桩端也是同样算法双方约定一致即可。如果项目里对可靠性要求更高可以换成CRC16我在后面的代码示例里会附上CRC16版本的计算方式作为参考。2.3 Kotlin协议编解码类下面这个类实现了帧的拼装和应答帧的解析Kotlin直接可用。data class WifiConfig( val ssid: String, val password: String ) data class ProtocolResponse( val cmd: Byte, val payload: ByteArray ) { override fun equals(other: Any?): Boolean ... // 按需实现 } object ConfigProtocol { const val FRAME_HEAD_1: Byte 0xAA.toByte() const val FRAME_HEAD_2: Byte 0x55.toByte() const val CMD_QUERY_INFO: Byte 0x01 const val CMD_ENTER_CONFIG: Byte 0x02 const val CMD_SET_WIFI: Byte 0x03 const val CMD_SET_RESULT: Byte 0x04 const val CMD_QUERY_STATUS: Byte 0x05 /** 生成查询设备信息指令 */ fun buildQueryInfo(): ByteArray buildFrame(CMD_QUERY_INFO, ByteArray(0)) /** 生成设置WiFi指令 */ fun buildSetWifi(config: WifiConfig): ByteArray { val ssidBytes config.ssid.toByteArray(Charsets.UTF_8) val pwdBytes config.password.toByteArray(Charsets.UTF_8) val data ByteArray(2 ssidBytes.size 2 pwdBytes.size) var index 0 data[index] (ssidBytes.size and 0xFF).toByte() data[index] ((ssidBytes.size shr 8) and 0xFF).toByte() ssidBytes.copyInto(data, index) index ssidBytes.size data[index] (pwdBytes.size and 0xFF).toByte() data[index] ((pwdBytes.size shr 8) and 0xFF).toByte() pwdBytes.copyInto(data, index) return buildFrame(CMD_SET_WIFI, data) } /** 拼接一帧完整报文 */ private fun buildFrame(cmd: Byte, data: ByteArray): ByteArray { val length 1 data.size val frame ByteArray(2 2 length 2) frame[0] FRAME_HEAD_1 frame[1] FRAME_HEAD_2 frame[2] (length and 0xFF).toByte() frame[3] ((length shr 8) and 0xFF).toByte() frame[4] cmd data.copyInto(frame, 5) var sum 0 for (i in 2 until 4 length) { sum frame[i].toInt() and 0xFF } frame[4 length] (sum and 0xFF).toByte() frame[5 length] ((sum shr 8) and 0xFF).toByte() return frame } /** 解析桩端返回的报文粘包/拆包处理在Socket读取层做 */ fun parseResponse(buffer: ByteArray): ProtocolResponse? { if (buffer.size 7) return null if (buffer[0] ! FRAME_HEAD_1 || buffer[1] ! FRAME_HEAD_2) return null val length (buffer[2].toInt() and 0xFF) or ((buffer[3].toInt() and 0xFF) shl 8) if (buffer.size 4 length 2) return null var sum 0 for (i in 2 until 4 length) { sum buffer[i].toInt() and 0xFF } val actual (buffer[5 length].toInt() and 0xFF) or ((buffer[6 length].toInt() and 0xFF) shl 8) if (actual ! (sum and 0xFFFF)) return null val payload buffer.copyOfRange(5, 4 length) return ProtocolResponse(buffer[4], payload) } }如果产品要求更高把累加和换成CRC16也很简单android.util.CRC16在API 24以上可以直接用或者自己实现一张查表法CRC表逻辑不变只替换校验函数就行。3. Android端配网流程设计状态机驱动避免流程乱成一团配网不是一个“发个指令等结果”的线性操作中间要处理权限申请、WiFi切换、Socket连接、超时重试每一环节都可能失败。我习惯把整个流程建模成状态机每个步骤有明确状态和退出条件这样代码可维护性高出问题也好定位。3.1 状态定义与流转我定义了这些状态enum class ConfigState { IDLE, // 空闲 REQUESTING_PERMISSION, // 权限申请中 SCANNING_AP, // 扫描桩热点 CONNECTING_AP, // 连接桩热点 ESTABLISHING_CHANNEL, // 建立Socket连接 QUERYING_DEVICE, // 查询设备信息 SENDING_WIFI, // 下发WiFi配置 WAITING_RESULT, // 等待桩上报配置结果 BACK_HOME_WIFI, // 回连原路由器 VERIFY_ONLINE, // 验证设备是否上线 SUCCESS, // 配网成功 FAILED // 配网失败 }把流程拆成状态之后每个步骤都只关心自己的小逻辑不跟其他步骤耦合。比如在CONNECTING_AP失败时App只需要把状态置为FAILED并提示用户“未检测到充电桩热点请确认桩已上电且处于配网模式”不需要关心后续指令流程。3.2 各状态的处理逻辑与转移条件我列一个简化的状态流转表方便你理解当前状态处理动作成功转移失败转移IDLE开始配网REQUESTING_PERMISSIONFAILEDREQUESTING_PERMISSION申请定位/附近WiFi权限SCANNING_APFAILEDSCANNING_AP扫描SSID前缀匹配的热点CONNECTING_APFAILED超时CONNECTING_AP用WifiNetworkSpecifier/legacy API连接热点ESTABLISHING_CHANNELFAILEDESTABLISHING_CHANNEL建立Socket连接并发送查询指令QUERYING_DEVICEFAILED重试QUERYING_DEVICE等待设备信息应答SENDING_WIFIFAILEDSENDING_WIFI发送0x03指令WAITING_RESULTFAILEDWAITING_RESULT等待桩端0x04上报或0x03应答BACK_HOME_WIFIFAILED */BACK_HOME_WIFI断开热点回连原WiFiVERIFY_ONLINEFAILED回连失败可接受VERIFY_ONLINE局域网发现/云端查询设备状态SUCCESSFAILED重试N次注意表中BACK_HOME_WIFI的一行我是特意标了“回连失败可接受”。原因后面会讲实际处理时不要把这一步设成致命错误。3.3 代码层面的状态机执行器用Kotlin的协程实现起来很顺手每个状态当成一个挂起函数状态流转通过返回值决定class ConfigStateMachine( private val context: Context, private val wifiHelper: WifiHelper, private val socketClient: ConfigSocketClient, private val onStateChange: (ConfigState) - Unit ) { private var state ConfigState.IDLE private val targetSsidPrefix ChargePile_ private val apIp 192.168.4.1 private val apPort 6000 suspend fun start(config: WifiConfig): ConfigResult withContext(Dispatchers.IO) { state ConfigState.REQUESTING_PERMISSION onStateChange(state) // 权限检查与申请由Activity/Fragment层处理这里假设已通过 runCatching { doConfig(config) } .fold( onSuccess { ConfigResult.Success }, onFailure { e - state ConfigState.FAILED onStateChange(state) ConfigResult.Failed(e.message ?: unknown error) } ) } private suspend fun doConfig(config: WifiConfig) { state ConfigState.SCANNING_AP onStateChange(state) val apSsid scanAndFindTarget() ?: throw AppException(未找到充电桩热点) state ConfigState.CONNECTING_AP onStateChange(state) val connected wifiHelper.connectToAp(apSsid) if (!connected) throw AppException(连接充电桩热点失败) delay(800) // 等待网络状态稳定 state ConfigState.ESTABLISHING_CHANNEL onStateChange(state) socketClient.connect(apIp, apPort) state ConfigState.QUERYING_DEVICE onStateChange(state) val infoResp socketClient.sendAndReceive(ConfigProtocol.buildQueryInfo()) val info ConfigProtocol.parseResponse(infoResp) ?: throw AppException(查询设备信息失败) // 可以根据设备类型做兼容分支 state ConfigState.SENDING_WIFI onStateChange(state) val setResp socketClient.sendAndReceive(ConfigProtocol.buildSetWifi(config)) val setResult ConfigProtocol.parseResponse(setResp) ?: throw AppException(设置WiFi响应解析失败) if (setResult.payload[0] ! 0.toByte()) { throw AppException(桩拒绝接收WiFi配置) } state ConfigState.WAITING_RESULT onStateChange(state) // 等待桩端主动上报0x04最多等8秒 val finalResp waitForSetResult() if (finalResp ! null finalResp.payload[0] ! 0.toByte()) { throw AppException(mapToUserError(finalResp.payload[0])) } socketClient.disconnect() state ConfigState.BACK_HOME_WIFI onStateChange(state) wifiHelper.disconnectAndReconnectHome() state ConfigState.VERIFY_ONLINE onStateChange(state) val verified verifyDeviceOnline() if (!verified) throw AppException(设备未上线请检查路由器是否正常) state ConfigState.SUCCESS onStateChange(state) } private suspend fun waitForSetResult(): ProtocolResponse? { // 在Socket的输入流上用while循环读取配合超时时间 return socketClient.waitForCommand(ConfigProtocol.CMD_SET_RESULT, timeoutMs 8000) } }这种写法把整个配网流程“钉死”即便以后要改协议、加状态也只需要增删状态和对应的处理函数不会动到UI层。4. WiFi连接切换Android各版本的适配与踩坑如果只说指令协议那这文档也就值一半。真正把配网成功率拉低的大头是Android设备在“连接桩热点”和“回连原WiFi”这两个动作上的系统差异。我在这部分吃过特别多亏。4.1 权限版本差异与Manifest配置首先要清楚不同Android版本要求哪些权限Android版本关键权限说明Android 6~8ACCESS_FINE_LOCATIONWiFi扫描结果会附带位置信息必须申请定位权限Android 9ACCESS_FINE_LOCATION同上连接WiFi也需要Android 10~12ACCESS_FINE_LOCATION扫描和连接都需要定位权限Android 13NEARBY_WIFI_DEVICES替代定位权限成为附近WiFi的主要权限如果仍要扫描结果还需要定位权限Android 14NEARBY_WIFI_DEVICES 定位/Manifest里这么写uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.ACCESS_COARSE_LOCATION android:maxSdkVersion32 / uses-permission android:nameandroid.permission.ACCESS_WIFI_STATE / uses-permission android:nameandroid.permission.CHANGE_WIFI_STATE / uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / uses-permission android:nameandroid.permission.NEARBY_WIFI_DEVICES android:usesPermissionFlagsneverForLocation /注意android:usesPermissionFlagsneverForLocation这个属性如果你声明这个App不会从WiFi扫描结果里推导用户位置Google Play审核会看重这个声明加了之后权限弹窗文案也会更友好。但如果你确实用扫描结果里的BSSID做过地理位置推断就别加这个flag加了属于滥用权限会被下架。4.2 Android 10以下addNetwork enableNetworkAPI 28及以下可以用传统的WifiManager.addNetwork()enableNetwork()前提是申请了CHANGE_WIFI_STATE权限。SuppressLint(MissingPermission) private fun connectLegacy(ssid: String, password: String, isOpen: Boolean false): Boolean { val wifiManager context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager if (!wifiManager.isWifiEnabled) { wifiManager.isWifiEnabled true // 等待WiFi打开 Thread.sleep(500) } val config WifiConfiguration().apply { this.SSID \$ssid\ if (isOpen) { allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE) } else { preSharedKey \$password\ allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK) } status WifiConfiguration.Status.ENABLED } val netId wifiManager.addNetwork(config) if (netId -1) return false return wifiManager.enableNetwork(netId, true) }这里要特别注意两点一是SSID和preSharedKey必须带英文双引号二是连完桩热点之后最好把旧网络一并disableNetwork否则手机会在桩热点和原WiFi之间反复横跳。我见过一个设备特别迷明明已经连上桩了Tile却一直在4G最后发现是系统自动连回了原来的路由器。4.3 Android 10及以上WifiNetworkSpecifier的“用户确认”对话框API 29开始Google强烈推荐用WifiNetworkSpecifier来连接指定WiFi它最大的变化是系统会弹一个“允许此应用连接该网络吗”的对话框用户点“允许”之后才真正发起连接。这个设计让应用无法静默切换WiFi安全性提高了但对配网流程的影响就是多了一步用户交互。RequiresApi(Build.VERSION_CODES.Q) private fun connectWithSpecifier(ssid: String, password: String): Boolean { val specifier WifiNetworkSpecifier.Builder() .setSsid(ssid) .setWpa2Passphrase(password) .build() val request NetworkRequest.Builder() .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) .setNetworkSpecifier(specifier) .build() val connectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val callback object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { // 连接成功时回调可以在这里继续Socket流程 } override fun onUnavailable() { // 超时/用户拒绝 } override fun onLost(network: Network) { // 连接丢失 } } // 注意必须指定超时时间不指定会一直等待用户授权 connectivityManager.requestNetwork(request, callback, 15_000) return true // 实际结果要通过callback回调这里不能真正返回布尔 }有几个坑requestNetwork如果不加超时时间一旦用户忽略弹窗请求会一直挂着。用户点了“允许”之后系统可能仍然无法连接密码错误、信号弱onUnavailable会触发。用户点“拒绝”之后也是走onUnavailable你需要区分“超时”和“拒绝”可以在回调里做一个倒计时计时器辅助判断。连接成功后不要立刻建立Socket建议等onAvailable回调里再等个300~500毫秒让系统的网络栈完全切过来否则Socket大概率连不上。因为WifiNetworkSpecifier不能“静默连接”如果你的业务要求App在后台自动完成配网这条路就走不通只能继续用addNetwork方案但会被Google Play视为“非推荐API”需要做好应对审核的准备。4.4 回连原WiFi的“尽力而为”策略配网完成之后手机需要从桩热点切回原来的路由器或4G。这一步有两种做法第一种是直接忽略桩热点的网络记录把控制权交还给系统让系统按优先级自动回连原WiFiSuppressLint(MissingPermission) fun disconnectApAndReconnectHome(apSsid: String) { val wifiManager context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager val apSsidWithQuotes \$apSsid\ val apNetworks wifiManager.configuredNetworks.filter { it.SSID apSsidWithQuotes } apNetworks.forEach { wifiManager.removeNetwork(it.networkId) } // 触发一次重新扫描/连接让系统自己找到最优网络 wifiManager.disconnect() wifiManager.reconnect() }第二种是用ConnectivityManager.bindProcessToNetwork()强制App进程先走某个网络通道。比如在回连原WiFi的过程中系统网络没切换完成但App又需要马上查询设备是否上线可以把当前进程绑定到默认网络或指定网络避免“明明WiFi还没连上App却还是拿着旧连接在发请求”。实际项目里我建议把“回连原网络”标记为非致命步骤因为有些用户压根没有可回连的路由器桩装在地下车库手机靠着桩热点配完网之后只能切回4G。如果强行要求手机必须回连原WiFi才算成功这部分用户就被卡死了。正确做法是断开桩热点之后让系统自己选网络App同时通过UDP广播发现设备或者轮询云端设备状态只要设备上线就算配网成功。5. 局域网Socket通信与指令收发时钟、超时与粘包Socket是配网里的“最后一公里”指令发不出去、应答读不回来前面WiFi连接再顺也没用。5.1 连接参数与超时策略桩热点的默认IP是192.168.4.1端口由固件决定常见的是6000、8266、9090具体以固件团队的约定为准。连接参数我总结了几个经验值参数推荐值说明Socket连接超时3000ms手机已连热点IP层应该秒通超过3秒基本是热点没起来或信号差Socket读超时3000~5000ms桩端处理一条指令通常在几百毫秒内完成指令发送间隔≥100ms防止桩端缓冲区来不及处理连续多发会丢帧等待0x04上报8000ms桩端收到配置后要去连路由器这个耗时最长要留足时间整体配网超时60s超过直接判定失败并释放资源5.2 一个健壮的Socket客户端封装class ConfigSocketClient( private val bufferedTimeoutMs: Int 3000 ) { private var socket: Socket? null private var outputStream: OutputStream? null private var inputStream: InputStream? null private var buffer ByteArray(0) fun connect(host: String, port: Int) { disconnect() val s Socket() s.connect(InetSocketAddress(host, port), 3000) s.soTimeout bufferedTimeoutMs s.tcpNoDelay true // 关闭Nagle算法减少小报文延迟 socket s outputStream s.getOutputStream() inputStream s.getInputStream() } fun sendAndReceive(request: ByteArray): ByteArray { write(request) return readFrame() } fun waitForCommand(cmd: Byte, timeoutMs: Int): ProtocolResponse? { val start SystemClock.elapsedRealtime() while (SystemClock.elapsedRealtime() - start timeoutMs) { val remain timeoutMs - (SystemClock.elapsedRealtime() - start).toInt() if (remain 0) break socket?.soTimeout remain try { val frame readFrame() val resp ConfigProtocol.parseResponse(frame) if (resp?.cmd cmd) return resp } catch (e: SocketTimeoutException) { // 没有等到继续循环 } } return null } private fun write(request: ByteArray) { outputStream?.write(request) outputStream?.flush() } private fun readFrame(): ByteArray { while (true) { // 先尝试从已有缓冲区解析一帧 ConfigProtocol.parseResponse(buffer)?.let { // 这里只是判断可用真正解析在返回后做 } // 简易方式读固定长度等完整帧后再解析 val temp ByteArray(512) val len inputStream?.read(temp) ?: -1 if (len -1) throw IOException(channel closed) buffer buffer temp.copyOf(len) val parsed ConfigProtocol.parseResponse(buffer) if (parsed ! null) { return buffer } } } fun disconnect() { runCatching { socket?.close() } socket null outputStream null inputStream null buffer ByteArray(0) } }要说明的是上面这个readFrame()是简化写法它会把整个buffer返回由调用方再调一次parseResponse。真实生产环境你需要处理“一次读到多帧”和“一帧被拆成两次读”的情况这里我建议把缓冲区设计成ByteArrayOutputStream循环读入每读一段就尝试解析解析成功就把已经消费掉的字节移除再继续解析剩余部分。这属于TCP拆包粘包的常规操作不在这里展开了。5.3 指令交互的时序桩端收到0x03设置WiFi指令后通常有两种反馈方式方式A桩端先同步应答0x03载荷里的结果表示“指令接收成功”然后桩端异步去连路由器连成或失败后通过0x04主动上报状态。方式B桩端一直不主动上报App只能发0x05去轮询状态。我强烈建议产品设计采用方式A因为App端等一条“接收成功”的消息只需要几百毫秒用户体验非常清爽而方式B需要App每2秒轮询一次轮询三五次还没结果用户就会怀疑是不是卡死了。如果桩端固件给的是方式BApp侧轮询的退出条件要注意连续10次查询到“连接路由器中”状态时不要直接判定失败因为WiFi模块重启、DHCP获取IP这些过程有时候要40多秒建议给足90秒的总等待时间。轮询间隔从前4次用1秒之后拉长到3秒减少无效流量。6. 配网结果验证设备上线的局域网发现机制“WiFi账号密码已下发”和“设备真正上线成功”是两个完全不同的概念。我见过不少App在0x03指令得到成功应答之后就提示“配网完成”用户回头一看充电桩根本没上线这种体验非常糟糕。6.1 为什么需要额外验证桩端在STA模式下连路由器时它自己无法告诉App“我上线了”因为连接路由器成功之后桩就脱离了手机所在的局域网如果手机还在桩热点上。App和桩想再次通信必须都连到同一个路由器的局域网里或者借助云端中转。所以配网结果验证本质上是回答一个问题桩是否拿到了路由器分配给它的IP并且能响应局域网的发现报文。6.2 UDP组播/广播发现方案这是最常用的验证方式。App在配网前先记录待配网桩的MAC地址从0x01应答里解析出来回连原路由器后App向局域网广播一个发现请求桩收到后单播回来一个包含自己IP和MAC的响应报文。class DeviceDiscoverer( private val context: Context, private val port: Int 6666 ) { private var socket: MulticastSocket? null private lateinit var multicastLock: WifiManager.MulticastLock SuppressLint(MissingPermission) fun startDiscovery(expectedMac: String, timeoutMs: Long): String? { val wifiManager context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager multicastLock wifiManager.createMulticastLock(charger_discovery) multicastLock.setReferenceCounted(false) multicastLock.acquire() return try { socket MulticastSocket(port).apply { soTimeout timeoutMs.toInt() reuseAddress true } val request FIND_CHARGER:$expectedMac.toByteArray() val sendPacket DatagramPacket( request, request.size, InetAddress.getByName(255.255.255.255), port ) socket?.send(sendPacket) val endTime System.currentTimeMillis() timeoutMs while (System.currentTimeMillis() endTime) { val buf ByteArray(256) val recvPacket DatagramPacket(buf, buf.size) socket?.receive(recvPacket) val msg String(recvPacket.data, 0, recvPacket.length, Charsets.UTF_8) if (msg.startsWith(CHARGER_ACK:$expectedMac)) { return recvPacket.address.hostAddress } // 如果收到的应答里MAC不匹配继续等 } null } catch (e: SocketTimeoutException) { null } finally { runCatching { socket?.close() } if (::multicastLock.isInitialized multicastLock.isHeld) { multicastLock.release() } } } }注意UDP广播在Android里默认被WiFi的组播策略挡住必须持有MulticastLock才能收到别人的广播报文。这不是什么黑科技是系统为了省电故意做的限制。MulticastLock一定要在finally里释放否则会导致WiFi功耗异常用户一晚上掉电20%你都不知道为什么。6.3 云端验证兜底如果充电桩是4G/NB-IoT和WiFi双模平台配网完成后可以通过云端接口查询设备是否在线。桩连上路由器之后它会主动和云端保持长连接或定时心跳云端侧自然知道设备状态。App调用云端接口“查设备在线状态”结果可靠且不受局域网限制。这种方式至少能作为UDP发现的兜底。6.4 验证失败的处理策略验证失败不等于配网失败也可能是桩连上路由器了但所在网段和手机不在同一个局域网比如路由器开了AP隔离或者手机走的是4G。此时App可以这样提示用户“WiFi配置已下发但未检测到设备上线。请确认充电桩指示灯状态若仍离线检查路由器是否开启AP隔离或MAC地址过滤。”这种文案比生硬的“配网失败请重试”要友好得多而且能引导用户去排查真正的问题。7. 实测复盘提高配网成功率的8个细节与常见坑做配网功能这一年多我把遇到的坑按影响面从大到小排了个序这些不是理论推演是真实用户反馈里反复出现的。挨个看一眼能帮你少走很多弯路。7.1 桩端热点是“开放网络”还是“加密网络”很多低成本WiFi模块出厂默认热点是开放网络不加密但有的模块固件会默认开WPA2。App端扫描识别热点时要能够读取到热点是否加密并用对应的方式去连接。Android的ScanResult.capabilities字段可以判断val isOpen scanResult.capabilities [ESS]如果固件是开放热点WifiNetworkSpecifier构建方式不同不要调用setWpa2Passphrase否则连不上。我见过一个团队因为拿错了测试桩一台开放热点、一台加密热点排查了两周才发现是加密方式不匹配导致连不上热点。7.2 2.4GHz与5GHz频段问题充电桩的WiFi模块普遍只支持2.4GHz频段。如果用户路由器开了双频合一手机可能连接的是5GHz而桩连不上5GHz。这时候不是发不出配网指令的问题而是桩热点的SSID压根扫描不到2.4G和5G在部分路由器上是两个独立SSID。处理建议配网引导页面明确提示“请确认路由器支持2.4GHz频段”。如果路由器开放了双频合一建议用户暂时关闭“双频合一”开关或提供单独2.4G SSID。在0x03指令下发后桩端如果多次连不上路由器可以上报错误码App根据错误码提示“请检查路由器是否为2.4GHz”。7.3 桩端旧配置残留充电桩出厂前如果已经被测试过配网桩热点可能不会开启因为模块优先去连上次配置的WiFi。用户拿到的桩如果是这种状态App扫描不到热点。解决办法有两种第一种App在扫描前提示用户“请确认充电桩指示灯处于待配网状态若已配过网请长按桩身按键5秒恢复出厂设置。”第二种桩端固件设计成“断网后自动回退到AP模式”如果WiFi模块连不上已保存的路由器路由器SSID变更、密码修改就自动重新开启热点。这个逻辑需要在固件端做App侧管不到但产品方案上一定要跟固件团队提前对齐。我在多个项目里的经验是只要桩的热点是靠“断电重启后先连旧网连不上再开AP”这种逻辑实现测试阶段就会经常遇到“热点时有时无”的诡异问题。后来统一改成“按钮手动进入配网模式”或者“断网自动回退AP”情况稳定多了。7.4 Socket连接被系统的网络切换打断手机连上桩热点之后如果系统在短时间内仍然判定“该网络无互联网”可能会自动切回4G此时Socket连接会直接断掉。这个问题在Android 12上尤其严重。应对方法在ConnectivityManager.registerNetworkCallback里监听网络变化当桩热点网络的onAvailable回调触发后再执行Socket连接。如果使用bindProcessToNetwork()把进程绑定到指定网络强制App不通过其他网络出口发数据。7.5 中文SSID与密码编码前文提过SSID、密码都按UTF-8编码长度按字节算。这块再补充一个细节某些路由器厂商对中文SSID的编码并不标准有的是GBK有的是UTF-8。WiFi模块在连接路由器时如果固件侧用GBK解析SSID而你App下发的是UTF-8字节桩会连不上、或者连上一个乱码SSID。最稳妥的做法是让固件团队在连接路由器时统一按UTF-8处理SSIDApp也按UTF-8下发两边一致即可。如果固件历史包袱重、改不动那App只能按固件的编码规则做一次转换。7.6 携带特殊字符的密码密码里如果包含、\、空格、$这类特殊字符发送的时候要原样传输不要做任何转义。我曾经在一个测试设备上因为历史代码里对密码做了一次URLEncoder.encode()导致密码里带号时比如Ab123桩一直连不上路由器排查了整整两天。7.7 路由器AP隔离、MAC过滤、隐藏SSID这三个几乎可以并成一个章节AP隔离路由器开了AP隔离即使桩和手机连的是同一个路由器两者也无法直接通信UDP发现会失败。MAC过滤充电桩的MAC不在白名单里路由器直接拒绝它接入。隐藏SSID桩连不上“隐藏SSID”的路由器多数WiFi模块扫描不到隐藏热点。App在配网引导页要把这些常见路由器配置项做成FAQ或提示列表用户遇到问题时可以自检。实测下来“路由器AP隔离”是最隐蔽的坑因为手机能上网、App能收到云端数据唯独局域网UDP发现报文丢了验证环节就会卡住。7.8 桩热点信号弱充电桩装在户外铁皮箱、车位上、墙角这类位置手机贴近桩身热点信号也可能只有一两格。Android手机在信号弱的时候系统可能根本无法连接热点或者连接后Socket频繁超时。App侧能做的在“连接热点”阶段反馈当前热点的信号强度提示用户靠近充电桩操作。Socket超时时间不要设太短连接超时保持3000ms读超时保持5000ms给弱信号留一点余量。重试时先断开热点再重连不要在同一条坏连接上反复发指令。7.9 配网完成之后的热点清理App在配网完成后需要主动“忘记”桩热点否则手机在日后经过充电桩时可能会自动连上桩的AP热点导致手机突然没有互联网。安卓的WifiNetworkSpecifier连上的网络默认是“仅临时连接”App退出或一定时间后系统会自动断开但传统addNetwork方式连上的热点会留在配置列表里必须清理。代码在4.4节已经给过示例这里再补一句清理的时候直接把匹配SSID的网络removeNetwork()然后调用wifiManager.reconnect()让系统重新扫描连接可用网络。8. 关于协议扩展与多桩共存的补充思考配网功能不是一次性做完就结束了。很多充电桩项目会演进到“一个App管理多个桩”的模式比如小区物业装了一批共享桩用户要能够发现自己附近的所有空闲桩并完成绑定。这要求配网阶段的指令协议和发现协议在设计之初就要支持多设备共存。8.1 多桩同时配网两个桩并排安装同时上电手机能扫描到两个热点。App如果只是SSID前缀匹配用户会分不清哪个是哪个。更好的做法是在扫描结果里同时展示信号强度与MAC地址后4位让用户根据桩身上的标签做选择。桩身的标签上如果用二维码那体验最好摄像头扫桩身上贴的二维码二维码内容里包含桩的MAC或SNApp再在扫描结果里自动匹配对应热点。如果没有二维码那退而求其次至少要把热点信号强度显示出来因为用户通常站在自己想配的那台桩旁边信号最强的那个就是要配的。8.2 协议版本兼容桩端固件会迭代配网协议也可能从v1升到v2。App端在发出0x01查询指令后拿到设备信息里的固件版本字段可以根据版本走不同的指令分支。如果协议变更较大建议在设备信息应答里加一个协议版本号字段App根据版本选择对应的编解码器。这个看起来有点“过度设计”实际上非常必要。我经历过一次固件升级后SSID长度从单字节扩展成双字节导致大批存量App配网发出去的数据是错的桩端解析乱套。从那之后我负责的客户端协议层一律带版本判断。8.3 充电桩配网与充电业务的边界充电桩App里配网功能只是整个业务链条的“入场券”。配完网之后还有设备绑定、远程启停充电、充电状态上报、计费结算等一堆事情。要注意的是配网通信和充电业务的通信通道往往不是同一个配网用的是桩AP模式的Socket充电业务用的是设备连上路由器后的MQTT/HTTP/私有TCP长连接。两套通道要严格分离不要在充电业务代码里掺和配网逻辑。这种边界如果捋不清很容易出现App断开桩热点后还在尝试用配网Socket去发充电指令必然失败还影响用户体验。最后再分享一个实用小技巧配网问题排查的时候最难受的是不清楚“手机到底连没连上桩热点”。我习惯在工程的debug包里去adb shell dumpsys wifi | grep mWifiInfo看当前连接状态但真实用户设备上没法这么做。所以我后来在App里内置了一个极简诊断页一键展示当前WiFi名称和IP地址桩热点是否扫描到Socket是否连上192.168.4.1:6000指令收发日志这个诊断页平时藏在一个开发者入口里用户配网失败时客服可以引导用户进入诊断页并截屏反馈能定位掉80%以上的问题。这个思路比什么都好使因为线上的问题大多数不是代码逻辑问题而是用户的网络环境、路由器设置、桩版本这些外部因素。只有拿到现场真实数据才能快速判断下一步该查哪里。充电桩的局域网配网说到底是个“稳”字每一步都确认到位每个状态都有超时每个异常都有用户看得懂的提示这个功能就算做扎实了。