C#通过树莓派(3B+)蓝牙来扫描设备

本文介绍了如何使用C#通过树莓派3B+的蓝牙模块来扫描周围的电子设备。详细讲解了涉及的NuGet包、关键类如NetworkPinger、SshCommunication以及BTDeviceScanner等,并提供了相应的代码片段,帮助开发者理解和实现类似功能。

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

最近用C#写个一个通过树莓派(Raspberry Pi)3B+的蓝牙模块来扫描电子设备。当时实现的时候也是花了些时间。现在把代码整理一下,给需要的人参考一下!

目录

NuGet Packages

NetworkPinger

SshCommunication

BTDevice

BTDeviceParser

BTDevicesExtensions

BTScanExecutor

ResultDirectoryService

BTDeviceScanner

TopProcessor

Console App


NuGet Packages

首先从NuGet上装几个package

  • log4net - 用来记录日志,实在太好用了!
  • SSH.NET - 用这个来实现通过SSH和树莓派进行通讯
  • System.ValueTuple - 这个是比较新的特新,VS2017默认不支持

 

接下里,我们就直接看代码。


NetworkPinger

这个class是用来检测能不能ping上树莓派。

    public interface INetworkPinger
    {
        bool CanPing(IPAddress ipAddress, int timeoutMilliseconds = 2000);
    }

    public class NetworkPinger : INetworkPinger
    {
        private readonly ILog _logger = LogManager.GetLogger(typeof(NetworkPinger));

        public bool CanPing(IPAddress ipAddress, int timeoutMilliseconds = 2000)
        {
            try
            {
                _logger.Info($"Try to ping {ipAddress}...");

                var ping = new Ping();
                var pingReply = ping.Send(ipAddress, timeoutMilliseconds);

                if (pingReply == null)
                    throw new Exception($"PingReply is null.");

                if (pingReply.Status != IPStatus.Success)
                    throw new Exception($"The status of PingReply \"{pingReply.Status}\" is not \"{IPStatus.Success}\".");

                _logger.Info($"Succeed in pinging {ipAddress}");
                return true;
            }
            catch (Exception exception)
            {
                _logger.Warn($"Some error happened while pinging {ipAddress}.\n{exception}");
                return false;
            }
        }
    }

 

SshCommunication

这个class就是调用用Renci.SshNet实现SSH通讯的。

  • 在调用参数中,有Func委托,委托也是实现松耦合的一种常用方式
    public interface ISshCommunicator : IDisposable
    {
        void Open();
        void Close();

        string WriteAndRead(
            string command,
            int timeoutMilliseconds = SshCommunicator.DefaultReadTimeoutMilliseconds);
        string WriteAndRead(
            string command,
            Func<string, bool> canStopEarlier,
            int timeoutMilliseconds = SshCommunicator.DefaultReadTimeoutMilliseconds);

        string ReadExistingString();
        string Read(
            Func<string, bool> canStopEarlier,
            int timeoutMilliseconds = SshCommunicator.DefaultReadTimeoutMilliseconds);
    }

    public class SshCommunicator : ISshCommunicator
    {
        public const int DefaultReadTimeoutMilliseconds = 5000;

        private bool _disposed = false;
        private SshClient _sshClient = null;
        private ShellStream _shellStream = null;
        private readonly ILog _logger = LogManager.GetLogger(typeof(SshCommunicator));
        private readonly ConnectionInfo _connectionInfo;

        public SshCommunicator(string ipAddress, string userName, string password)
        {
            _connectionInfo =
                new ConnectionInfo(
                ipAddress,
                userName,
                new PasswordAuthenticationMethod(userName, password))
                {
                    Encoding = Encoding.ASCII
                };
        }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (_disposed)
                return;

            if (disposing)
                Close();

            _disposed = true;
        }

        public void Open()
        {
            Close();

            _sshClient = new SshClient(_connectionInfo);
            _sshClient.Connect();

            _shellStream = _sshClient.CreateShellStream("BluetoothctlShell", 512, 128, 1920, 1080, 1024 * 10);
        }

        public void Close()
        {
            if (_sshClient == null)
                return;

            try
            {
                _logger.Info("Close SSH client...");

                _shellStream.Close();
                _sshClient.Disconnect();
            }
            catch (Exception exception)
            {
                _logger.Warn($"Some error happened while closing SSH client.\n{exception}");
            }
            finally
            {
                _shellStream = null;
                _sshClient = null;
            }
        }

        public string WriteAndRead(string command, int timeoutMilliseconds = DefaultReadTimeoutMilliseconds)
        {
            IgnoreExistingStringBeforeWrite();
            Write(command);
            return Read(timeoutMilliseconds);
        }

        private void IgnoreExistingStringBeforeWrite()
        {
            var existingString = ReadExistingString();
            if (!string.IsNullOrEmpty(existingString))
                _logger.Warn($"Ignore the below existing string before write.\n{existingString}");
        }

        public string WriteAndRead(
            string command,
            Func<string, bool> canStopEarlier,
            int timeoutMilliseconds = DefaultReadTimeoutMilliseconds)
        {
            IgnoreExistingStringBeforeWrite();
            Write(command);
            return Read(canStopEarlier, timeoutMilliseconds);
        }

        private void Write(string command)
        {
            _logger.Info($"Write \"{command}\"...");
            _shellStream.WriteLine(command);
        }

        public string Read(int timeoutMilliseconds = DefaultReadTimeoutMilliseconds)
        {
            _logger.Info($"Read the response within {timeoutMilliseconds} ms");

            var stringBuilder = new StringBuilder();
            var stopwatch = Stopwatch.StartNew();

            while (true)
            {
                DelayBetweenLoops();

                stringBuilder.Append(ReadExistingString());

                if (stopwatch.ElapsedMilliseconds > timeoutMilliseconds)
                {
                    _logger.Warn($"Timeout {timeoutMilliseconds} ms has happened. Stop reading more response.");
                    break;
                }
            }

            _logger.Info($"The response is as below.\n{stringBuilder}");
            return stringBuilder.ToString();
        }

        private static void DelayBetweenLoops()
        {
            Thread.Sleep(10);
        }

        public string Read(Func<string, bool> canStopEarlier, int timeoutMilliseconds = DefaultReadTimeoutMilliseconds)
        {
            if (canStopEarlier == null)
                throw new ArgumentNullException(nameof(canStopEarlier));

            _logger.Info($"Read the response within timeout {timeoutMilliseconds} ms");

            var stringBuilder = new StringBuilder();
            var stopwatch = Stopwatch.StartNew();

            while (true)
            {
                DelayBetweenLoops();

                stringBuilder.Append(ReadExistingString());

                if (canStopEarlier(stringBuilder.ToString()))
                {
                    _logger.Info("Expected response has been found. Stop reading more response.");
                    break;
                }

                if (stopwatch.ElapsedMilliseconds > timeoutMilliseconds)
                {
                    throw new Exception($"Timeout {timeoutMilliseconds} ms has happened. The response is as below.\n{stringBuilder}");
                }
            }

            _logger.Info($"The response is as below.\n{stringBuilder}");
            return stringBuilder.ToString();
        }

        public string ReadExistingString()
        {
            var stringBuilder = new StringBuilder();

            while (_shellStream.DataAvailable)
            {
                stringBuilder.Append(_shellStream.Read());
                Thread.Sleep(50); // Need this delay !!!! Otherwise some response can be incomplete.
            }

            return stringBuilder.ToString();
        }
    }

 

BTDevice

这个class就是保存扫描到的蓝牙设备的地址,同时把每次扫到的RSSI也保存下来。

    public class BTDevice
    {
        private readonly List<int> _rssis = new List<int>();
        private readonly ILog _logger = LogManager.GetLogger(typeof(BTDevice));

        public string BTAddress { get; }

        public BTDevice(string btAddress)
        {
            BTAddress = btAddress;
        }

        public BTDevice(string btAddress, int rssi)
            : this(btAddress)
        {
            AddRSSI(rssi);
        }

        public BTDevice(string btAddress, IEnumerable<int> rssis)
            : this(btAddress)
        {
            AddRSSIs(rssis);
        }

        public void AddRSSI(int rssi)
            => _rssis.Add(rssi);

        public void AddRSSIs(IEnumerable<int> rssis)
            => _rssis.AddRange(rssis);

        public override string ToString() => $"{BTAddress}({string.Join(",", _rssis)})";

        public IEnumerable<int> RSSIs => _rssis;

        public bool HasAnyRSSI => _rssis.Any();

        public int GetFinalReportingRSSI()
        {
            if (!HasAnyRSSI)
                throw new Exception($"No any RSSI for {BTAddress}.");

            var average = (int)_rssis.Average();
            _logger.Info($"Take the average {average} of ({string.Join(",", _rssis)}) as final RSSI reported.");
            return average;
        }
    }

 

BTDeviceParser

这个class会解析bluetoothctl返回的字符串,只针对期望的蓝牙地址。

    public class BTDeviceParser
    {
        private string _scanResponse;
        private string[] _btAddressesToScan;

        public BTDevice[] Parse(string scanResponse, IEnumerable<string> btAddressesToScan)
        {
            _scanResponse = scanResponse;
            _btAddressesToScan = btAddressesToScan.ToArray();

            var btDevices = new List<BTDevice>();

            foreach (var btAddress in _btAddressesToScan)
            {
                var success = TryParse(btAddress, out var btDevice);
                if (success)
                    btDevices.Add(btDevice);
            }

            return btDevices.ToArray();
        }

        private int[] FindRssisFor(string btAddress)
        {
            var rssis = new List<int>();

            var fullMatchPattern = btAddress + @" RSSI: \-?\d+";
            var fullMatches = Regex.Matches(_scanResponse, fullMatchPattern);

            if (fullMatches.Count > 0)
            {
                foreach (Match fullMatch in fullMatches)
                {
                    var rssi = int.Parse(Regex.Match(fullMatch.Value, @"\-?\d+", RegexOptions.RightToLeft).Value);
                    rssis.Add(rssi);
                }
            }

            return rssis.ToArray();
        }

        private bool HasFound(string btAddress)
        {
            return Regex.IsMatch(_scanResponse, btAddress);
        }

        private bool TryParse(string btAddress, out BTDevice btDevice)
        {
            btDevice = null;

            var rssis = FindRssisFor(btAddress);

            if (rssis.Length > 0)
            {
                btDevice = new BTDevice(btAddress, rssis);
                return true;
            }
            else
            {
                if (HasFound(btAddress))
                {
                    btDevice = new BTDevice(btAddress);
                    return true;
                }
                else
                    return false;
            }
        }
    }

 

BTDevicesExtensions

这是IEnumerate<BTDevice>的扩展方法。有时实现扩展方法,可以让代码更具有可读性。

    public static class BTDevicesExtensions
    {
        public static bool HasAllFound(this IEnumerable<BTDevice> btDevicesScanned, IEnumerable<string> btAddressesToScan)
        {
            if (btDevicesScanned == null)
                throw new ArgumentNullException(nameof(btDevicesScanned));

            if (btAddressesToScan == null)
                throw new ArgumentNullException(nameof(btAddressesToScan));

            var btAddressArrayToScan = btAddressesToScan.ToArray();
            if (btAddressArrayToScan.Length < 1)
                throw new ArgumentException("Should have one BT Address to scan at least", nameof(btAddressesToScan));

            var btAddressesScanned =
                btDevicesScanned
                    .Where(m => m.HasAnyRSSI)
                    .Select(m => m.BTAddress)
                    .ToArray();

            return
                btAddressArrayToScan
                    .All(m => btAddressesScanned.Contains(m));
        }
    }

 

BTScanExecutor

我们接下来看看通过bluetoothctl发送那些命令就可以实现蓝牙设备的扫描。

    public class BTScanExecutor
    {
        private readonly string[] _btAddressesToScan;
        private readonly ILog _logger = LogManager.GetLogger(typeof(BTScanExecutor));
        private readonly INetworkPinger _networkPinger = new NetworkPinger();
        private readonly Config _config;
        private ISshCommunicator _sshCommunicator;

        public BTScanExecutor(string[] btAddressesToScan)
            : this(btAddressesToScan, new CachedConfigLoader())
        { }

        public BTScanExecutor(string[] btAddressesToScan, IConfigLoader configLoader)
        {
            _btAddressesToScan = btAddressesToScan;
            _config = configLoader.Load();
        }

        public BTDevice[] FindBTDevices()
        {
            ValidateRPiPingable();
            return ExecuteScan();
        }

        private BTDevice[] ExecuteScan()
        {
            BTDevice[] btDevices;

            using (_sshCommunicator = new SshCommunicator(_config.IpAddress, _config.UserName, _config.Password))
            {
                Open();
                ReadStartPrompt();
                EnterBluetoothctl();
                PowerOnController();
                btDevices = ScanBTDevices();
                ListDevices();
                ExitBluetoothctl();
                CloseSsh();
            }

            return btDevices;
        }

        private BTDevice[] ScanBTDevices()
        {
            var overallBTDevices = new List<BTDevice>();
            var scanner = new BTDeviceScanner(_sshCommunicator, _btAddressesToScan);

            const int countOfTries = 3;
            for (var i = 0; i < countOfTries; i++)
            {
                _logger.Info($"Start {i + 1}/{countOfTries} scan...");

                if (i > 0)
                    _logger.Warn("Retry to scan BT devices !!!!");

                var btDevices = scanner.ScanBTDevices();
                foreach (var btDevice in btDevices)
                {
                    var singleOrDefault = overallBTDevices.SingleOrDefault(m => m.BTAddress == btDevice.BTAddress);

                    if (singleOrDefault == null)
                        overallBTDevices.Add(btDevice);
                    else
                        singleOrDefault.AddRSSIs(btDevice.RSSIs);
                }

                _logger.Info($"Overall BT Devices detected:\n{string.Join("\n", overallBTDevices)}");

                if (overallBTDevices.HasAllFound(_btAddressesToScan))
                    break;
            }

            return overallBTDevices.ToArray();
        }

        private void ValidateRPiPingable()
        {
            _logger.Info("Start to ping RPi...");

            var pingable = _networkPinger.CanPing(IPAddress.Parse(_config.IpAddress), timeoutMilliseconds: 5000);
            if (pingable)
                _logger.Info("Succeed in pinging RPi");
            else
                throw new Exception($"Fail to ping {_config.IpAddress}");
        }

        private void Open()
        {
            _logger.Info("Open SSH");
            _sshCommunicator.Open();
        }

        private void ReadStartPrompt()
        {
            _logger.Info("Read start prompt...");

            _sshCommunicator.Read(s => s.Trim().EndsWith("pi@raspberrypi:~$"));
        }

        private void EnterBluetoothctl()
        {
            _logger.Info("Enter bluetoothctl...");

            ReadAndIgnoreExistingString();

            _sshCommunicator.WriteAndRead(
                "bluetoothctl",
                s => s.Contains("Agent registered"));

            ReadAndIgnoreExistingString();
        }

        private void PowerOnController()
        {
            _logger.Info("Power on Controller...");

            ReadAndIgnoreExistingString();

            _sshCommunicator.WriteAndRead(
                "power on",
                s => s.Contains("Changing power on succeeded"));

            ReadAndIgnoreExistingString();
        }

        private void ListDevices()
        {
            _logger.Info("List BT devices detected...");

            ReadAndIgnoreExistingString();

            _sshCommunicator.WriteAndRead(
                "devices",
                s => s.Contains("[bluetooth]"));

            ReadAndIgnoreExistingString();
        }

        private void ExitBluetoothctl()
        {
            _logger.Info("Exit bluetoothctl...");

            ReadAndIgnoreExistingString();

            _sshCommunicator.WriteAndRead(
                "exit",
                s => s.Trim().EndsWith("pi@raspberrypi:~$"));

            ReadAndIgnoreExistingString();
        }

        private void CloseSsh()
        {
            ReadAndIgnoreExistingString();

            _logger.Info("Close SSH");
            _sshCommunicator.Close();
        }

        private void ReadAndIgnoreExistingString()
        {
            var existingString = _sshCommunicator.ReadExistingString();

            if (!string.IsNullOrEmpty(existingString))
                _logger.Warn($"Some existing response has been read but ignored.\n{existingString}");
        }
    }

 

ResultDirectoryService

这个简单的类就是把扫描以文本的方式保存到制定目录。

    public interface IHostExeService
    {
        string GetExePath();

        string GetExeDirectory();
    }

    public class HostExeService : IHostExeService
    {
        public string GetExePath()
        {
            var processModule = Process.GetCurrentProcess().MainModule;

            if (processModule == null)
                throw new Exception("The ProcessModule that was used to start the process is null!");

            return processModule.FileName;
        }

        public string GetExeDirectory()
        {
            var filePath = GetExePath();
            return Path.GetDirectoryName(filePath);
        }
    }

    public interface IResultDirectoryService
    {
        void Clear();
        void Save(BTDevice btDevice);
    }

    public class ResultDirectoryService : IResultDirectoryService
    {
        private readonly ILog _logger = LogManager.GetLogger(typeof(ResultDirectoryService));
        private readonly IHostExeService _hostExeService = new HostExeService();
        private readonly string _resultDirectory;

        public ResultDirectoryService()
        {
            _resultDirectory = Path.Combine(_hostExeService.GetExeDirectory(), "Result");
        }

        public void Clear()
        {
            if (Directory.Exists(_resultDirectory))
            {
                foreach (var file in Directory.GetFiles(_resultDirectory))
                    File.Delete(file);
            }
            else
            {
                Directory.CreateDirectory(_resultDirectory);
            }
        }

        public void Save(BTDevice btDevice)
        {
            var fileName = btDevice.BTAddress.Replace(":", "") + ".txt";
            var filePath = Path.Combine(_resultDirectory, fileName);

            _logger.Info($"Write {btDevice} to {filePath}");
            File.WriteAllText(filePath, btDevice.GetFinalReportingRSSI().ToString());
        }
    }

 

BTDeviceScanner

    public class BTDeviceScanner
    {
        private readonly ISshCommunicator _sshCommunicator;
        private readonly string[] _btAddressesToScan;
        private readonly Config _config;
        private readonly ILog _logger = LogManager.GetLogger(typeof(BTDeviceScanner));
        private readonly BTDeviceParser _btDeviceParser = new BTDeviceParser();
        private Stopwatch _stopwatch;
        private StringBuilder _scanOnStringBuilder;
        private BTDevice[] _btDevices;

        public BTDeviceScanner(
            ISshCommunicator sshCommunicator,
            string[] btAddressesToScan)
        {
            _sshCommunicator = sshCommunicator;
            _btAddressesToScan = btAddressesToScan;
            _config = new CachedConfigLoader().Load();
        }

        public BTDevice[] ScanBTDevices()
        {
            _scanOnStringBuilder = new StringBuilder();
            _stopwatch = Stopwatch.StartNew();

            TurnOnScan();

            while (true)
            {
                Thread.Sleep(millisecondsTimeout: 500);

                AppendExistingString();

                UpdateBTDevices();

                if (ShouldStopDueToAllHaveBeenFound())
                    break;

                if (ShouldStopDueToAllHaveBeenFoundButNoneIsWithRssi())
                    break;

                if (ShouldStopDueToScanTimeout())
                    break;
            }

            _logger.Info($"The final scan response is as below.\n{_scanOnStringBuilder}");

            TurnOffScan();

            return _btDevices;
        }

        private void UpdateBTDevices()
        {
            _btDevices =
                _btDeviceParser
                    .Parse(_scanOnStringBuilder.ToString(), _btAddressesToScan);
        }

        private bool ShouldStopDueToScanTimeout()
        {
            var shouldStop = _stopwatch.ElapsedMilliseconds > _config.ScanMilliseconds;

            if (shouldStop)
                _logger.Warn($"Scan timeout {_config.ScanMilliseconds} ms has happened.");

            return shouldStop;
        }

        private bool ShouldStopDueToAllHaveBeenFound()
        {
            var shouldStop = _btDevices.HasAllFound(_btAddressesToScan);

            if (shouldStop)
                _logger.Info("All BT devices have been found. Stop this scan.");

            return shouldStop;
        }

        private bool ShouldStopDueToAllHaveBeenFoundButNoneIsWithRssi()
        {
            var response = _scanOnStringBuilder.ToString();

            var shouldStop =
                _btAddressesToScan
                    .Select(m => !Regex.IsMatch(response, m + @" RSSI: \-?\d+") && Regex.IsMatch(response, m))
                    .All(m => m);

            if (shouldStop)
                _logger.Warn("All BT addresses have been detected but none has RSSI reported. Stop this scan earlier.");

            return shouldStop;
        }

        private void AppendExistingString()
        {
            var existingString = _sshCommunicator.ReadExistingString();

            if (!string.IsNullOrEmpty(existingString))
            {
                _logger.Info($"More Scan On response was read.\n{existingString}");
                _scanOnStringBuilder.Append(existingString);
            }
        }

        private void TurnOnScan()
        {
            const int waitMilliseconds = 1000;

            _logger.Info($"Turn on scan and wait {waitMilliseconds} ms");

            _scanOnStringBuilder.Append(
                _sshCommunicator.WriteAndRead("scan on", waitMilliseconds));
        }

        private void TurnOffScan()
        {
            _logger.Info("Turn off scan");

            _sshCommunicator.WriteAndRead(
                "scan off", s => s.Contains("Discovery stopped"));
        }
    }

 

TopProcessor

顶层处理函数是这个样子的。

    public class TopProcessor
    {
        private readonly string[] _btAddressesToScan;
        private readonly IResultDirectoryService _resultDirectoryService = new ResultDirectoryService();
        private readonly ILog _logger = LogManager.GetLogger(typeof(TopProcessor));

        public TopProcessor(string[] btAddressesToScan)
        {
            _btAddressesToScan = btAddressesToScan;
        }

        public void Process()
        {
            ClearResultDirectory();
            var btDevicesScanned = ScanBTDevices();
            SaveBTDevices(btDevicesScanned);
        }

        private void ClearResultDirectory()
        {
            _resultDirectoryService.Clear();
        }

        private BTDevice[] ScanBTDevices()
        {
            _logger.Info($"Scan these BT devices\n{string.Join("\n", _btAddressesToScan)}");

            var scanExecutor = new BTScanExecutor(_btAddressesToScan);
            return scanExecutor.FindBTDevices();
        }

        private void SaveBTDevices(BTDevice[] btDevicesScanned)
        {
            foreach (var btAddress in _btAddressesToScan)
            {
                var firstOrDefault = btDevicesScanned.FirstOrDefault(m => btAddress.ToString() == m.BTAddress.ToString());

                if (firstOrDefault == null)
                {
                    _logger.Warn($"[{btAddress}]: Can't detect this device");
                }
                else
                {
                    if (firstOrDefault.HasAnyRSSI)
                    {
                        _logger.Info($"[{btAddress}]: Succeed in detecting this device");
                        _resultDirectoryService.Save(firstOrDefault);
                    }
                    else
                        _logger.Warn($"[{btAddress}]: Succeed in detecting this device, but no RSSI detected");
                }
            }
        }
    }

 

Console App

这个命令行程序的顶层代码

    class Program
    {
        private static readonly ILog Logger = LogManager.GetLogger(typeof(Program));

        static void Main(string[] args)
        {
            var stopwatch = Stopwatch.StartNew();

            try
            {
                Logger.Info("========== PROGRAM - START ==========");

                if (args.Length < 1)
                    throw new ArgumentException("Should have one argument at least");

                var btAddressesToFind = GetBTAddressesFrom(args[0]);
                Process(btAddressesToFind);
            }
            catch (Exception exception)
            {
                Logger.Error($"!!!! Some error happened. !!!!\n{exception}");
            }
            finally
            {
                Logger.Info($"Elapsed milliseconds: {stopwatch.ElapsedMilliseconds}");
                Logger.Info("========== PROGRAM - END ==========\n\n\n\n");
            }
        }

        private static string[] GetBTAddressesFrom(string argument)
        {
            return
                argument
                    .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
                    .Select(m => m.Trim())
                    .ToArray();
        }

        private static void Process(string[] btAddressesToFind)
        {
            var processor = new TopProcessor(btAddressesToFind);
            processor.Process();
        }
    }

 

如果有什么疑问,大家可以多交流交流。

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值