Android 蓝牙之坑(1)

翻了翻之前的post,发现写了很多关于android蓝牙的东西,每一次都是在不断的摸索,在进步。今天当然也不例外啦~不过我感觉在上一篇post之后,Android的
BLE应该算是摸的透了吧。直到前天~

Context

零零柒水壶架在配对的时候过程比较复杂,在绑定的时候会涉及到蓝牙操作,也会涉及到网络操作。我们的蓝牙在连接上之后也会需要一个认证才能通信。这段时间一帮德国用户反映,Android客户端在绑定的时候老是出幺蛾子。
在折腾了两周无果之后,我终于忍不住了,为什么我本地百测百通过的app,到了他们手里就百测百不通过了呢?于是发了一个跟踪所有log的版本给他们。我倒是要看看,出了啥毛病。

Trace

其实跟踪还是蛮方便的,这真的多亏了leancloud。

我做了一个新的Log类

import android.os.Build;

import com.avos.avoscloud.AVException;
import com.avos.avoscloud.AVObject;
import com.avos.avoscloud.SaveCallback;
import com.viking.locator.model.CarrierRecord;

public class Log {

    public static void apn(CarrierRecord carriar){
        if (carriar.isManaually()){
            AVObject debug = new AVObject("APN");
            debug.put("model", android.os.Build.MODEL);
            debug.put("brand", android.os.Build.BRAND);
            debug.put("version", Build.VERSION.RELEASE);
            debug.put("address",carriar.getApnAddress());
            debug.put("carriar",carriar.getCarrierName());
            debug.put("password",carriar.getPassword());
            debug.put("username",carriar.getUsername());
            debug.put("country",carriar.getCountryCode());
            debug.saveInBackground(new SaveCallback() {
                @Override
                public void done(AVException e) {
                }
            });
        }
    }

    public static void d(Object tag, String msg) {
        com.litesuits.android.log.Log.d(tag, msg);
//        debug(tag, msg);
    }

    public static void e(Object tag, String msg) {
        com.litesuits.android.log.Log.e(tag, msg);
//        debug(tag, msg);
    }

    public static void d(Object tag,String msg,boolean upload){
        com.litesuits.android.log.Log.d(tag, msg);
        if (upload){
            upload(tag,msg);
        }
    }

    public static void e(Object tag,String msg,boolean upload){
        com.litesuits.android.log.Log.e(tag, msg);
        if (upload){
            upload(tag,msg);
        }
    }

    private static void debug(Object tag,String msg){
        AVObject debug = new AVObject("DEBUG");
        debug.put("tag", tag.toString());
        debug.put("msg", msg);
        debug.put("model", android.os.Build.MODEL);
        debug.put("brand", android.os.Build.BRAND);
        debug.put("version", Build.VERSION.RELEASE);
        debug.saveInBackground(new SaveCallback() {
            @Override
            public void done(AVException e) {
            }
        });
    }

    private static void upload(Object tag, String msg) {
        AVObject debug = new AVObject("LOG");
        debug.put("tag", tag.toString());
        debug.put("msg", msg);
        debug.put("model", android.os.Build.MODEL);
        debug.put("brand", android.os.Build.BRAND);
        debug.put("version", Build.VERSION.RELEASE);
        debug.saveInBackground(new SaveCallback() {
            @Override
            public void done(AVException e) {
            }
        });
    }
}

这样只要替换之前的Log,就可以把log时候的tag,msg,包括手机型号什么的都打印上去。

而且leancloud的网页端查看起来也蛮方便~

status=22

按照正规流程,连上蓝牙发发数据,网络同步,整个过程大概就2-3秒。所以当初的当初,在设计这个配对的时候我压根就没有考虑过,蓝牙会在这短短短短的几秒钟断开。
因为数据量也很小,时间也很短,因此状态基也没有涉及到蓝牙断开的状态。反而设计了很多蓝牙读写失败的没用状态~

而恰恰就是这2-3秒,有的Android手机还真的要给你断个线~

从google官方文档看,在BluetoothGattCallback里面的onConnectionStateChange接口里面可以得到蓝牙GATT的连接状态。而且上一篇post也列举了4种可能出现的状态。

可是,这4种并不是全部状态!

我发现在德国用户的手机上出现了status=22,new_status=DISCONNECTED这样的状态,官方文档只说了status为GATT_SUCCESS(0),GATT_FAILURE(257)。那这个22是啥状态呢!

你猜!

感觉google你是在玩我~

所以上一篇post里的蓝牙被动断开应该不仅仅是status=GATT_FAILURE,new_status=DISCONNECTED。应该是status不为GATT_SUCCESS,new_status为DISCONNECTED的都算!鬼知道status还会出现什么值呢~经过google,没有正确答案,或许这就是玄学吧!

当然,在google的时候,还看到有兄弟遇到了status=8的情况,他们也是一脸懵逼~

END

所以我打算开一个系列,专门用来写Android BLE的坑!

Android 蓝牙连接状态

Context

最近做了三个涉及蓝牙的小应用,都有Android版本。说实话,写了一年多Android(完完整整的写)。我对Android的蓝牙链接状态还是存在一些不明确的地方。

毕竟最早接触的是iOS的蓝牙开发,接触新领域的时候总是会根据以前的认知来加以理解。

iOS

iOS上面对蓝牙外设的连接状态是通过BluetoothCentralManager来告知的。

@protocol CBCentralManagerDelegate <NSObject>

//外设链接成功
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral;
//外设连接失败
- (void)centralManager:(CBCentralManager *)central didFailToConnectPeripheral:(CBPeripheral *)peripheral error:(nullable NSError *)error;
//外设已断开连接
- (void)centralManager:(CBCentralManager *)central didDisconnectPeripheral:(CBPeripheral *)peripheral error:(nullable NSError *)error;

所以蓝牙是否连接成功是通过中心来通知的,但是在Android上,连接状态是由外设来通知的

Android

public final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
    @Override
    public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
        super.onConnectionStateChange(gatt, status, newState);
    }
}

其中status代表了GATT操作的成功与失败,newState代表了GATT的新状态

status有GATT_SUCCESS,GATT_FAIL

newState有STATE_CONNECTED,STATE_CONNECTING,STATE_DISCONNECTED,STATE_DISCONNECTING

所以这个回调函数可以出现8种状态。

State

那么,对比上面iOS的三个协议,列一张对比表

状态 iOS Android
已连接 didConnectPeripheral GATT_SUCCESS+STATE_CONNECTED
已断开 didDisconnectPeripheral GATT_SUCCESS+STATE_DISCONNECTED,GATT_FAIL+STATE_DISCONNECTED
连接失败 didFailToConnectPeripheral GATT_FAIL+STATE_CONNECTED

可以看到已断开这里,android有两种可能性,第一种是系统底层发现连接断开了GATT_FAIL+STATE_DISCONNECTED,另外一种是用户控制断开GATT_SUCCESS+STATE_DISCONNECTED。
这并不是说明iOS的接口设计的不够周到,而只是iOS把断开的原因放在了error里面而已。

Disconnect

在iOS里断开外设的连接实在是简单。只要让centralManager cancel Connection就可以了。但是在android中,会发现有两个方法~disconnect(),close()

感觉好像两个方法都是对的样子,但实际上他们是有区别的。

BLE通信从底层来说,是在每个connection中传递数据的。所以disconnect是指停止connection通信,但是并没有断开连接。当gatt disconnect了之后,再使用close()便可以真正的断开连接。

假设直接使用close()会怎样呢?

其实不会怎样,蓝牙照样断开了,只不过不会在BluetoothGattCallback中通知。这或许对于开发者来说,会比较麻烦。因为像我就是在BluetoothGattCallback中来定义外设的连接状态,并使用event来通知相关的组件和界面做操作的。
直接close()对于蓝牙来说就会出现一个延迟,因为没有disconnect的蓝牙外设完全断开需要花点时间。

End

这一篇blog是写了一年多Android蓝牙后,从一次次的项目中慢慢总结的。现在回过去看最早最早做的蓝牙类,那是真的漏洞百出啊。

现在感觉自己封装的蓝牙库,和iOS越来越接近了。这样以后写完iOS,再写android会更方便~有点儿爽。

Android和iOS中的socket超时问题

咱们牛逼的新产品——码表,在数据同步上使用蓝牙和Wi-Fi两种同步方式,其中Wi-Fi同步是让码表和手机处在同一局域网内,然后通过一个协议来传输数据。

很容易想到Socket。

Socket,提供了IP层的数据传输业务。让两个节点通信成为了可能。

Socket有TCP和UDP传输两种方式。据说现在最先进的是TCP,它是对UDP的升级。从技术角度说,UDP是一个过时的技术。那么,它真的没用了吗?

简单一搜索,发现现在还是有很多业务是使用UDP的,而且用更先进的TCP反而效果不理想。

TCP

之前水壶架硬件和服务器交互的时候,使用的是TCP来传输。TCP的优点就是,保证数据正确、完整、已送达。它有强大又复杂的重传校验等机制。保证了可靠性。

而且,由于TCP是一个长链接,所以实时性也非常的好。

所以我们二话没说,就开始使用TCP传输。

Android TCP

其实android直接用系统的接口就可以很方便的进行socket操作。

注意,Socket是必须在新线程中操作的

初始化一个socket实例,获取它的input和output的流,对流进行读写,就相当于用socket进行收发了。

不过这里有一个问题,那就是你很难知道自己是否还连着socket,除非用一个写操作,出现异常。

因为他的isConnected或者isClosed方法都是获取本地状态的,并不知道远程服务器是否还连接着。

所以stackoverflow上的解决方法是,发送心跳包…发送一个urgentData来确定服务器是否还可达。

说实在的,这个解决方法,真的垃圾。

Timeout

那么超时呢?

我觉得完全没法做。读取流的时候,当前线程就完全阻塞了。根本没有机会去检查等待时间,而且完全无法中断读取的操作…………

iOS TCP

iOS上用CocoaAsyncSocket这个网络库来实现socket通信。实在是方便。

他的读和写可以增加Tag,方便实用状态基。并且有读取指定长度这种操作,还可以设置超时。

实在是太方便了,所以我都不想多说了……

BUT

写到这里,是比较了Android原生TCP超精简实现和iOS超厉害的框架实现的TCP通信。

当我根据协议把一切的一切写好了之后,联调时候发现了严重的丢帧问题。

从TCP的理论来说,丢帧了,应该会自动重传。但是并没有收到新的帧。上下位机都停在了某一个状态。

经过调试,我们发现问题出在了下位机发送数据和接受数据的地方。下位机发送数据后会收到TCP的响应。而这时候手机也发了协议中的响应数据回来。这就导致了下位机串口收到的数据,出现了粘包情况。导致了状态异常。从上位机的角度来说,自己的发送的响应已经得到了响应,会认为下位机已经收到了响应……

对,状态乱了,也不同步了。

所以我们在上位机这里增加了延迟机制,保护下位机脆弱的心灵。但是这并没有从根本上解决这个问题,而且这会大大增加传输需要的时间。(经过测试,发现稳定的用了延迟方法传输一个文件的速率是蓝牙的3倍)

UDP

这肯定是不能被接受的!

虽然说Wi-Fi的理论速度是用MB做单位的,但是由于我们硬件的各种限制。这个数字会大大大大的缩小。但是也不至于缩小到3kb左右~

我们通过进一步调试,发现主要问题还是出现在下位机TCP响应和数据包的粘连情况。所以,为了解决这个棘手的问题,不如直接把这个响应去掉算了!

Android UDP

UDP在Android中也有很现成的接口。

使用DatagramSocket类来操作udp。

udp可以算是数据包了,它是不基于连接的。但是它还是需要端口来通信。所以对于下位机来说,不能直接将数据通过一个连接写入了。得确定目标节点的地址和端口才能将数据返回回去。

由于之前TCP版本抽象的比较好,所以转换到UDP比较轻松。

Timeout

关键的一点是,android的DatagramSocket类自带了timeout属性!在等待数据进入的时候,可以设置一个timeout。一旦超时,直接抛出异常。之后就可以进入重传流程!

iOS UDP

CocoaAsyncSocket这个库必然也是提供了UDP操作的,不过可气的是,这次他不提供timeout这个操作了!

Timeout

所以iOS的超时就需要自己写了……

其实想想还是比较简单的。只要在发送完毕之后启动一个定时器,在x秒后检查是否有数据即可。

其中的难点大概就是,如何确定是否有数据进入,以及如何确定进入的数据是不是之前开启定时器时候的数据的回复信息……

所以,特地引入了一个变量

NSDate *reading_timestamp;

读取数据的时间。

一个定时检查的方法

-(void)check_receive:(NSTimeInterval)timestamp{
    if (reading_timestamp.timeIntervalSince1970==timestamp) {
    //timeout!!
    NSLog(@"timeout !");
    }
//.......
}

然后在需要回馈的发送中添加了一些赋值操作。

-(void)udpSocket:(GCDAsyncUdpSocket *)sock didSendDataWithTag:(long)tag{
    isSending=NO;
    if(tag>0&&tag<FitFileTransmissionStatusDone){
        transmissionStatus=(FitFileTransmissionStatus)tag;
        reading_timestamp=[NSDate date];
        NSTimeInterval interval=reading_timestamp.timeIntervalSince1970;
        [sock receiveOnce:nil];
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), queue, ^{
            [self check_receive:interval];
        });
    }
}

这里要注意,interval这个中间变量是一定要写的,不写会死。具体原因,就和block的机制有关了,想通过我的笔记了解的话,可以看看之前的一片关于危险的block的记录。

在延迟的block中比较全局的开始读取的时间和执行block前的时间局部的时间,来确定全局的读取时间是否有改变。用这个思路成功的解决了这个问题!!

End

通过UDP,传输稳得很,而且速度是蓝牙的19倍左右!这才叫爽。

全文只有iOS的Timeout贴了代码,因为。别的代码实在是太简单了,而且网上一查一大片。只有iOS这一段是我原创的~哈哈

iOS线性布局?

前几天通过比较了iOS和Android端BLE蓝牙开发时,订阅Characteristic的不同,得出了Android订阅特征相当复杂的结论。但是在结尾我也替Android开发洗了一次白,因为今天说的这个东西,在iOS上非常难搞,而在Android上相当容易!

Context

新的码表产品,是一款为广大骑行爱好者省钱的GPS码表!

这个硬件会记录骑行时候的速度,位置,海拔,心率,踏频,等等等等信息,并在表盘显示实时的状态。所以,手机的用处就是,将骑行记录导出,然后对数据进行备份,为骑行者分析骑行过程。

咱们不仅仅是价格很厉害,在同步方式上也很厉害。因为我们可以通过Wi-Fi同步,而不仅仅是蓝牙同步。Wi-Fi的速度可比蓝牙高得多。不过这些都不是今天的主题,今天是要说app中,展示某一次骑行详情时候的图表展示。

废话不多说,先上图

iOS
Android

这里的图表的表项,是不确定数量的。比如说,这个用户没有踏频器的话,那么数据分析中就会没有踏频的图表。没有心率计的话,就会没有心率的图表。

Android

Android实在是,太方便了吧!

用了LinearLayout之后,我们把所有有可能出线的图表,都写在LinearLayout中,外面再套一个ScrollView。

不需要的图表我们直接把它setVisibility(View.GONE)就完事儿了,后面的图表会紧紧的挨上来,不会留出一个大空白来。

而且ScrollView会随着LinearLayout的大小来确定是否可以滑动,最多可以滑动多少距离。

iOS

没有LinearLayout,只有frame或者auto layout,你说怎么办?

用框架!

是的,有MyLinearLayout,很方便,就是对标Android的LinearLayout做的,其原理也是逃不掉frame和auto layout的。

如果我用框架做,那还写这篇干什么呢~

因为如果不手动写,是完全不会遇到UIScrollView和AutoLayout的一个大坑的!

Step 1

想法很简单,我只要给一个变量叫做lastChart,其实就好了~

@interface ChartViewController () {

    UIView *lastChart; //用了表示上一个图表的指针

    UIScrollView *scrollV;
    UIView *contentView;
    __weak ActivityDetailPresenter *presenterReference;

    ChartBlockView *distanceView;
    ChartBlockView *timeView;
    ChartBlockView *cadenceView;
    ChartBlockView *heartView;

}
@end

这里ChartBlockView表示图表,他是UIView的子类,所以lastChart完完全全可以引用他们。

Step 2

每次从Presenter中reload图表数据的时候,我会一个图表一个图表的去渲染。

比如,我先去检查,有没有速度信息,有的话就搞一个速度的表,没有的话就跳过。

再获取踏频的数据,有数据搞一个踏频的表,没有的话就跳过。

每次在渲染一个表的时候,会去检查lastChart是否为nil,如果为nil,那top就以顶端为参考;如果不为nil,那top就以lastChart的bottom做为参考。

根据这个准则,便很容易实现。

Step 3

当我兴高采烈的run了之后,发现可以显示,但是scrollview完全不能滑动!

查看了view Hierarchy,发现UIScrollView的ContentSize竟然是(0,0)!

因为……autolayout并不能填充scrollview……

所以,后来我又花了些时间,解决了这个问题……

End

所以,iOS没有LinearLayout还是蛮讨厌的……

虽然iOS原生推出了一个新的控件,就是LinearLayout,但是需要iOS 11……

预知后事如何,请听下回分解……

BLE订阅多特征

之前记录过很多Android和iOS的BLE的开发。不过他们都有一个共性,就是上位机都只订阅了一个特征值。

Context

这次,我们需要订阅两个特征了。

iOS

iOS是真的方便,直接订阅了就好了。

- (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(nullable NSError *)error {
NSArray *chs = service.characteristics;
for (CBCharacteristic *ch in chs) {
    if ([ch.UUID.UUIDString isEqualToString:kCharacteristicControlPoint]) {
        ControlPointCharacteristic = ch;
        // 订阅它!
        [peripheral setNotifyValue:YES forCharacteristic:ControlPointCharacteristic];
    } else if ([ch.UUID.UUIDString isEqualToString:kCharacteristicDataReceiver]) {
        DataReceiverCharacteristic = ch;
    } else if ([ch.UUID.UUIDString isEqualToString:kCharacteristicDataSender]) {
        DataSenderCharacteristic = ch;
        // 订阅它!
        [peripheral setNotifyValue:YES forCharacteristic:DataSenderCharacteristic];
    }
    if (ControlPointCharacteristic != nil && DataSenderCharacteristic != nil && DataReceiverCharacteristic != nil) {
        //All set up
    }
}

我查到一个需要订阅的characteristic,直接执行订阅就可以了。非常的简单,没什么好说的~

Android

我以为,Android大概也是这么简单的吧~

@Override
    public void onServicesDiscovered(BluetoothGatt gatt, int status) {
        super.onServicesDiscovered(gatt, status);
BluetoothGattService service = gatt.getService(UUID.fromString(ServiceUUID));
    if (service != null) {
        fit_control = service.getCharacteristic(UUID.fromString(ControlPointCharacteristic));
        fit_receiver = service.getCharacteristic(UUID.fromString(DataReceiverCharacteristic));
        fit_sender = service.getCharacteristic(UUID.fromString(DataSenderCharacteristic));
        if (fit_control != null && fit_receiver != null && fit_sender != null) {
            // 订阅它!
            gatt.setCharacteristicNotification(fit_control, true);
            // 订阅它!
            gatt.setCharacteristicNotification(fit_sender, true);
        }
    }
}

结果,死都收不到fit_sender的数据,我甚至以为是硬件的问题了~

后来我想到了,N久以前看到过一个帖子,说是Android BLE开发的一些坑,里面提到了android的蓝牙底层是串行的,没有队列。一次只能做一件事情,多的事情,会被忽略。

所以我得等到一次订阅成功之后,再订阅下一个特征。然后我看到

gatt.setCharacteristicNotification()

这个方法是有返回值的,而且还是个boolean,说不定是成功或者失败的意思。官方的解释是true, if the requested notification status was set successfully,貌似正合我意。

看了一下他的实现源码,在这里有关registerForNotification()没有办法继续反编译了

try {
    mService.registerForNotification(mClientIf, device.getAddress(),
        characteristic.getInstanceId(), enable);
} catch (RemoteException e) {
    Log.e(TAG,"",e);
    return false;
}

所以我也不知道这个返回值有没有卵用~

可是仔细想了一下,这个方法既然不是同步的(执行在这里并不会卡住一段时间去订阅这个Characteristic)那就说明这个返回值,可能并没有乱用,说不定他只是去判断一下这个characteristic是否拥有notify的权限而已。

而且即便获取到了这个返回的boolean值,我又能怎么做呢?写个while(1)?

How does it subscribe

或许,我需要的,是一个回调方法吧~

那么,蓝牙到底是怎么做订阅这个动作的呢?

肯定不是本地订阅一下就完事儿了的,肯定会让remote知道,有个client订阅了自己!

所以,一定是要写入数据的!

于是,在Google很偏僻的文档里面,找到了如下的代码段:

private BluetoothGatt mBluetoothGatt;
BluetoothGattCharacteristic characteristic;
boolean enabled;
...
mBluetoothGatt.setCharacteristicNotification(characteristic, enabled);
...
BluetoothGattDescriptor descriptor = characteristic.getDescriptor(UUID.fromString(SampleGattAttributes.CLIENT_CHARACTERISTIC_CONFIG));
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
mBluetoothGatt.writeDescriptor(descriptor);

是啊,除了本地要开始监听之外,还要写一个描述,告诉BLE设备,我开始订阅了,请开启Notification。

而,writeDescriptor()是由回调的!

public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status);

只要在回调函数里面再去订阅下一个characteristic,再在回调函数里面再再再去订阅下下个characteristic……就可以实现订阅很多很多很多的characteristic了!

Key

@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
    super.onServicesDiscovered(gatt, status);        ...
    gatt.setCharacteristicNotification(fit_control, true);
    List<BluetoothGattDescriptor> descriptorList = fit_control.getDescriptors();
    if (descriptorList != null && descriptorList.size() > 0) {
        for (BluetoothGattDescriptor descriptor : descriptorList) {
            descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
            gatt.writeDescriptor(descriptor);
        }
    }    
 }

 @Override
 public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
        super.onDescriptorWrite(gatt, descriptor, status);
        // 订阅下一个
 }

End

其实,还是蛮折腾的~以前没遇到过这个问题,而且android官方例程里面也没有说这个问题,所以还是花了很多时间。赶紧记录一下,免得以后再次遇到这样的坑儿。

不要以为这个例子就说明了Android比iOS开发难。明天我就写一篇,iOS上花了我差不多一天时间,而Android上半个小时就搞定的东西!

2017 Time

8th Year

每年都写是一个很好的习惯,每年写年记的时候,总是可以翻翻去年的,前年的东西,看看以前的自己是不是还是很幼稚还是很傻。

总觉得,人只有进入社会了,才会真正的长大。体会过了社会的残酷,才会真正的长大。明白生活的艰辛,才会真正的长大。这一年好多同学都好像突然的长大了一样,和他们交流起来感觉自己真的是天真无邪,还是个孩子一样。

说起来,我真的算是进入了半个社会了吧。每天差不多都在工作,或许未来的几十年都是这样的生活。或许每天的生活就是,工作,吃饭,睡觉。然后……过完了很多年。

想想时间真的是很可怕啊,离30岁真的越来越近了。回想2017年,自己都干了一些什么,大体一看,只有工作。做了很多的项目,优化了很多工程,就过完了。一天一天流的很快,那个困扰了我几年的问题也离我越来越近。

Time is limited

生活就那么些日子,不把他们充实起来,真的很不划算。我不想等我快死了的时候,才开始后悔好多事情没做过,好多东西还不会,一些成就还没有解锁……

可是生活就是这么无奈,不是每个人都那么好命,可以天天潇洒。即便真的有人可以天天潇潇洒洒的玩,我觉得到老了的时候,也会后悔自己没有体会过其他的事情,没有体会过成功和胜利的喜悦。

Life is hard

从2014年我们开始推出第一个产品炫轮;到2016年的尾灯,防盗水壶架;到今年的速度器、踏频器、ANT-USB发射器、智能打气筒、GPS码表。我们的研发能力再提升,产品线在扩展,展览会也是说话更有底气。看起来一切都不错,但是细分到每一天,我们过的都不容易。每天都有人加班。每个人手上都要维护很多产品,需要同时去处理每个产品的事情。大家真的很不容易,虽然说今年目标依然没有达成,但是每年都比上一年好,这样的趋势还是会给人带来希望的。

越接近毕业,就越接近社会。很早的朋友们会在一起讨论怎么样怎么样做一个好玩的东西。什么什么技术好厉害给你安利一下……现在大家都在说钱,房子,车,工作,去哪儿……这是一个必然的趋势,谁都逃不走。就像我攒了那么多年的钱,还是付不起成都房子的首付(只是个人爱好,并不是要真的买)。生活实在是不容易啊,到处都要花钱,各种小消费累计在一起可以变成一个令人难过的数字。

长大了,再也不能为所欲为的消费了。不能为了买个X装逼而花光积蓄了。不能为了装逼去买顶配MacBook了。不能再任性的买耳机了。需要开始合理的消费,或许锤子的坚果Pro2就很好呢,获取Google的Nexus 6P就很好呢,或许华为的线控耳机就很好了呢?挣钱是很不容易的,不应该为了装逼而任性……

I can

即使生活再怎么艰难,也不能自暴自弃,该干的还是要硬着头皮的干!

这一年真的学了很多新的东西,感觉自己的功力又一次大涨了。

这一年,我写了几个服务器,几个网站,几个Android,几个iOS。在应用层,大概可以算个全栈了吧。虽说每个领域都不是神级人物,但是只要你给个正常的需求,我想我都是可以搞得定的。

拿了驾照,这么多年了,一直没有去学。总是找借口说自己忙,忙的没时间去学。但是真的挤一下,安排一下时间,还是可以搞得定的。

硬件!从最早喜欢钢铁侠开始,我就没有放弃做硬件的梦想。当然啦,路要一步一步走,饭要一口一口吃,不能扯着蛋,慢慢学,所以嵌入式也开始搞起来了。

OLD

年末突然冒出了一个新的词语,叫做佛系。我觉得我也是个佛系青年了,各种无所谓,各种随便。

今年也看到了一个行业的衰落。通信,一个很牛逼的行业,一个看起来很火热的行业,在一个前辈的悲剧发生后露出了原形。

不仅通信如此,互联网其实也在衰落,只不过大家都看不到。前几年互联网形势大好,老一辈的程序员们纷纷投入移动互联网行业;新一辈的学生党页纷纷投入互联网行业。也是,人越来越多。虽然每个程序员都告诉自己,不能一辈子做程序员,以后要做个管理项目,管理团队的人。可是,金字塔的形状决定了管理者的人数,未来很多老一辈的程序员可能只能继续硬着头皮的拍代码,不然就只能被淘汰。

这个现象已经出现在了现在的中年程序员群中了,那我呢?会不会很快的就遇到危机呢?

End

2018,24岁了,两轮生肖了。

生活总是有压力的,但是做人还是要潇洒。

该吃吃,该喝喝。

有兴趣的工作,要尽力去做;没兴趣又烦人的工作,该丢就丢。

不会庸庸碌碌,也不会平淡无奇。

或许生活就是这样的呢?

G3-PLC 学习历程

Context

好像很久很久没有更新博客了,最近事情确实太多了。新产品的app,教研室新项目,两个大头。还有毕业设计的开题,虽然我想好了题目,但是,磨人的开题报告,也是个很头疼的东西。

这次教研室的项目是电力线通信,使用G3-PLC协议,这也是第一次做一个偏硬件的项目了,一开始老师在问大家有没有兴趣的时候,还是蛮犹豫的,但是还是因为兴趣,决定搞一下试试。

花了几周的时间好好的熟悉了一下这个协议。在电力线通信的场景中,会有很多意想不到的情况。比如A->B是直连的,B->A就不能直连,需要绕一条路走。而且电力线网络的拓扑结构是会随时随刻在改变的,这一时刻你和他直连,下一时刻他就不见了。

对于这种物理层这么不可靠的连接,需要在它的基础上构建一个相对可靠的网络,还是比较麻烦的。G3-PLC借鉴了ZigBee的技术,并且引入了6LowPan技术,使得这个网络的可靠通信成为可能。

ZigBee?

ZigBee不是无线的嘛?

这电力线是有线连接的,竟然还需要用无线的协议。其实他们还是挺像的,ZigBee每个节点的发射功率有限,为了与不在射程范围内的节点进行通信,就只能通过其他节点来帮助它传递消息。电力线虽然说大家都是有线连接在一根主线(总线)(插线板)上,但是他的衰减比较严重,那么中间节点就需要充当一下”中继”,将收到的消息进行转发,以实现全网的通信。

6LowPan?

这个概念倒是很有意思,大家都知道ipv4的地址就要分配完了,所以赶紧推广ipv6是非常重要的,而物联网时代,网络节点的数量会比互联网时代大的更多,毕竟每个物联网外设都很便宜,他们要入网也需要一个地址来标识。大概是出于这一点的考虑,G3-PLC在设计的时候就引入了6LowPan,是一种有超大地址空间的一个协议。这个名字很奇特6LowPan,他的意思就是低功耗的,ipv6的,个域网。

为了低功耗,它甚至省去了很多数据,通过减少数据量来降低功耗,真是很拼。

Router

网络通信,最重要的一个部分,莫过于路由了。

G3-PLC的适配层用了LOADng协议,这个东西的资料比较的少。不过另一个东西的资料我在图书馆里找到了很多——AODV,是Ad Hoc网络中的按需询问的路由协议。LOADng是,新一代轻量级按需路由协议。

怎么个轻量级,我现在还真是说不准,按照我自己的猜想,这个轻量级只是减少了一些操作,少维护一些内容,数据发送频率低一些。

比如说,在刚加入网络的时候,AODV协议里需要节点对整个网络进行一下”摸索”,比如发送RREQ询问一下距离本节点x跳的各位节点,维护一张路由表。中间节点也会根据这个RREQ请求,自己建立一张表,在未来出现链路中断现象的时候可以发送RERR信息告诉上游节点,路线断了。

不过,按照我的理解,以及我对官方给的非常简略的协议的理解,以及我对借的各种书的理解,我觉得这个低功耗大概是第一次获取路由的时候只获取一跳的节点、出现断路不通知、之类的操作。通过这一系列的优化来达到轻量级低功耗的目的。

Oops…

其实在理解这个协议的时候还是走了不少弯路的,比如我一直无法理解自己的地址是怎么来的,直到我明白了这个系统有coordinator和device两种设备。我一直不知道我怎么获取别人的地址,知道我想明白,这个地址不用我们获取,而是上层指使就好了。

这个项目从最早的无从下手,到现在感觉有把握了。我觉得自己还是学到了不少新东西,说明自己还没有老。作为一个学生还是要有这种学习能力的嘛~

下面大概要好好研究一下Keil和嵌入式开发了!

东西多啊

最近真的是很忙呢。

上周刚刚把公司官网勉强整好,写前段并不是主要的目的。做官网主要的目的是学习一些先进的前端技能。虽然光靠html,css,js就确实可以完成所有的网站需求了,但是,要提高效率,与时俱进,这些必然是不够的。所以通过做官网,我顺便补充了一些技能,什么node.js,gulp,vue.js,angular之类的高端东西。不过实在是太忙了,所以就只能空了再整理到博客里面去。

教研室项目也是很多……包括做不出来的,以及不可能做出来的东西…

比如二维码防伪,你别以为是简单的用二维码防伪…而是验证一个二维码是不是真的,仔细想想,是不是没可能…?

G3PLC电力线通信协议??好难啊!如果我能把这个做出来,估计可以在嵌入式领域装个逼了~哈哈

还剩一个比较轻松的,就不用说了。

12月要出一个新产品,感觉时间也是非常的紧。虽然前面花了一些时间,整了一下socket的知识,但是感觉这个项目还是蛮大的(而且有iOS和Android两个版本呢)…希望可以按期完成吧。

生活很艰辛,抽空还是要吃几盘鸡的,对吧!

扫码登录(3)

Context

Http是在tcp的基础上做了高级封装,变成了一个被动的,短链接请求.请求完了就把连接断开了,下一次要请求,就需要再一次进行tcp握手,然后通信……在前面第一篇中,我用了定时轮训的方式问服务器,当前这个二维码是否登录,从功能的角度来说,应该是没有问题的,而且写起来也是特别的方便.

不过仔细的想一想,其实还是比较浪费资源的.从信息论的角度来说,中间的N次质询,相当于没有任何的信息量(请不要深究这一句),所以,我们不用http来更新扫码状态!

我们用

Web Socket!

Web Socket

关于websocket的定义,解释什么的,百度一下,你全都知道了,所以我就不在这个地方多说.

只说明一点,websocket屌的飞起,和socket的操作一毛一样,关键是服务器终于可以主动的发消息给客户端啦!

他算是一个长连接,握手之后,就开始了肮脏的通信,通信完了,一方可以发起关闭操作,然后……,最后连接就拆掉了.

Django

websocket怎么说也就只能算是对web服务器的一个扩充,如何在已有的web服务器上快速集成一个websocket的服务,才是比较重要的~

由于之前用的是django做服务器,所以我们就找找给django加上websocket的方法~

在他的官方文档中提到了很多可用的开源app,其中有一个叫做channels的库,他用了asgi,直接可以快速集成在django中,牛的一批.

Channels

这个库的文档,真的是无力吐槽,我真是集成的很辛苦

我们来简化一下他的文档吧!

Router

和django一样,websocket的连接也要有个路由的,我们先建立一个py文件

from channels.routing import route
# 这个文件等会儿解释
from users.webClient import connection_handler, disconnection_handler,subscribe_token

channel_routing = [
    route("websocket.connect", connection_handler, path=r"/ws/$"),
    route("websocket.disconnect", disconnection_handler, path=r"/ws/$"),
    route("websocket.receive", subscribe_token, path=r"/ws/$"),
]

这里的websocket.connect,websocket.disconnect,websocket.receive相当于是websocket的三种事件,已连接、已断开、收到消息

中间那个参数和django里面的那个描述一样,就是该路由对应的处理方法.

Handler

现在来解释一下上一节说的 users.webClient这个文件

# -*- coding: utf-8 -*-

import json
from channels.sessions import channel_session
from users.qrcode_helper import *


@channel_session
def connection_handler(message):
    message.reply_channel.send({"text": json.dumps({"status":False,"msg":"connected"})})


@channel_session
def subscribe_token(message):
    try:
        obj = json.loads(message.content["text"])
        tokens = obj["token"]
        qrcode_record = fetch_qrcode_record_with_token(token=tokens)
        if qrcode_record is not None:
            if is_token_pass_the_authentic(tokens):
                message.reply_channel.send(
                    {"text": json.dumps({"status": True, "msg": "succeed"})})
            else:
                message.reply_channel.send(
                    {"text": json.dumps({"status": False, "msg": "succeed"})})
                qrcode_record.webSocket_session = str(message.content["reply_channel"])
                qrcode_record.save()
                # link token to this channel
        else:
            message.reply_channel.send({"text": json.dumps({"status": "-1", "msg": "token invalid"})})
            message.reply_channel.send({"close": True})
    except Exception as e:
        message.reply_channel.send({"text": e.message})
        message.reply_channel.send({"close": True})


@channel_session
def disconnection_handler(message):
    channel = str(message.content["reply_channel"])
    qrcode_records = fetch_qrcode_record_with_web_socket_channel(channel)
    if qrcode_records is not None:
        for qrcode_record in qrcode_records:
            qrcode_record.webSocket_session = ""
            qrcode_record.save()

很长,也不用仔细看,因为里面主要做了扫码登录里面的事情,如果只是要集成一下websocket的话,不用关心这些方法里面的操作,只要注意这里每个方法的传入参数 message

Message有那些用处呢?

1.通过message.channel或者message.reply_channel可以获取到连接的通道(请暂时不要使用高级操作)
2.message.content其实是一个字典,这个字典遵循了ASGI的消息模型,里面有text,reply_channel等等字段

在我的处理代码里面,我从message.content里面取出了reply_channel,他的value是一个长得很奇怪的字符串(我把它类比成文件描述符把),毕竟要有很多很多很多连接的话,它们的文件描述符不能重复对吧,所以就又长又难看,比如像daphne.response.FSITXWDDzG!IOtDNILqzh

有了这个reply_channel,在后面主动发送消息下去的时候,就可以指定连接发送了~

Attention

注意,这里的websocket.connect的handler里面一定一定,千万千万要给反馈消息回去,不然浏览器(只测试了Chrome的)会以为连接超时的!然后给你一个503 bad gateway,之后就主动断开连接了,留你一脸蒙逼的在那儿傻看着.

Send

现在我们可以看看之前第一章中的app调用的扫码接口了

@csrf_exempt
@require_POST
@pass_auth
@require_parameter(["code"])
def allow_the_qrcode_login(request):
    code = request.POST["code"]
    user = get_user_from_response_session(request)
    qr_record = fetch_qrcode_record_with_code(code)
    if user is not None and qr_record is not None:
        if qr_record.user_id is not None:
            return JsonResponse({"msg": "expired", "status": -1}, status=400)
        qr_record.user_id = user.user_uuid
        qr_record.status = True
        qr_record.save()
        if qr_record.webSocket_session is not None:
            # send new status to the web socket
            channel = Channel(name=qr_record.webSocket_session)
            content = {"text": json.dumps({"status": True, "msg": "succeed"})}
            channel.send(content)
            pass
        return JsonResponse({"msg": "succeed", "status": 0}, status=200)
    return JsonResponse({"msg": "code not existed", "status": -400}, status=400)

这里加了一些些小的改动,我再把他取出来

if qr_record.webSocket_session is not None:
    channel = Channel(name=qr_record.webSocket_session)
    content = {"text": json.dumps({"status": True, "msg": "succeed"})}
    channel.send(content)

websocket.receive的时候,我把websocket的文件描述符记录在了qrcode的记录中,这样扫描某一个qrcode的时候,就可以知道这个二维码是否有websocket正在订阅他,有订阅的话,就可以拿来对指定的通道进行发送了

End

网页端真的没什么好说的,直接贴(里面没有很严密的控制权限,稍微改一改就行,但是我懒)

function listen_for_status() {
    socket = new WebSocket("ws://" + window.location.host + '/ws/');
    socket.onmessage = function (e) {
        var obj = JSON.parse(e.data);
        if (obj.status == true) {
            socket.close();
            window.location="dashboard.html";
        }
    };
    socket.onopen = function () {
        var obj = {};
        obj.token = token;
        socket.send(JSON.stringify(obj));
    };
}

还有不要忘记更新nginx的配置

扫码登录的这个小玩意儿,虽然看起来很是简单,但是也耗了我很多的时间,这个过程中,也是学到了很多新的东西呢!

这次也是达成了一个新的成就:WebSocket(Server&Client)

扫码登录(2)

Context

几天前把扫码登录的服务器总结了一下,由于时间太晚,篇幅太长,而且涉及的内容是两个板块的,所以就没有在那一篇文章中写app端的一些实现。

事实上,app这里并没有简单多少,主要的难点大概就是扫码这里吧~之前虽然是在Alinone中做过扫码的功能,但是当时使用了cocoapods里面的一个库,所以没有怎么深入的理解AVFoundation。这次,我就偏偏不要用开源库,读读官方文档,把AVFoundation的一些功能搞搞明白。

How does it work

好好研究了官方文档一番,我大概明白了一件事儿,整个视频流的工作过程是由一个会话(AVCaptureSession)来控制的,这个会话有输入(AVCaptureInput)和输出(AVCaptureOutput)

当session开始工作的时候,数据就从输入经过会话传到了输出,当然这里说的全都是抽象类,具体到这个app里面的话,输出就是AVCaptureOutput的一个子类AVCaptureMetadataOutput

这玩意儿就是专门用来解析视频流信息,提取其中的metadata的,其中包括BarCode、QRCode等其他code~牛逼的很

Make it

这次没有用什么传说中的设计模式来写这个demo,因为这样会有点儿小题大做的感觉,直接在VC中定义了一切。

@interface MainViewController ()<AVCaptureMetadataOutputObjectsDelegate>
{
    AVCaptureVideoPreviewLayer *previewLayer;
    dispatch_queue_t avProcessQueue;
    IBOutlet UIView *scan_view;
    AVCaptureSession *avCaptureSession;
    AVCaptureDevice *avDevice;
    AVCaptureDeviceInput *avInput;
    AVCaptureMetadataOutput *metaOutput;
    NSMutableArray *capturedList;
}
@property (nonatomic, weak) IBOutlet UIButton *scanCodeButton;

@end

注意这里有个avProcessQueue(dispatch_queue_t),因为视频处理是比较消耗资源和时间的,所以如果让他们在主线程中运行的话,必死,所以要单独搞一个线程出来让它们运行。

AVCaptureVideoPreviewLayer是CALayer的一个子类,可以方方便便的把AVSession里面的内容显示出来,如果没有这个东西的话,就很难想象如何把摄像头捕捉到的东西显示在界面上了~

capturedList是一个用来扫描结果的纪录列表,后面会解释他存在的意义。

Configure AVFoundation Elements

- (void)viewDidLoad {
    [super viewDidLoad];
    // 建立一个新的队列(线程)
    avProcessQueue = dispatch_queue_create( "session queue", DISPATCH_QUEUE_SERIAL );

    // 初始化设备
    AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];

    // 初始化Session
    avCaptureSession=[[AVCaptureSession alloc]init];
    [avCaptureSession setSessionPreset:AVCaptureSessionPresetHigh];

    // 初始化Input
    NSError *error;
    avInput=[[AVCaptureDeviceInput alloc]initWithDevice:device error:&error];
    [avCaptureSession addInput:avInput];

    // 初始化Output
    metaOutput =[[AVCaptureMetadataOutput alloc]init];
    [metaOutput setMetadataObjectsDelegate:self queue:dispatch_get_main_queue()];
    [avCaptureSession addOutput:metaOutput];
    metaOutput.metadataObjectTypes = @[AVMetadataObjectTypeQRCode];

    // 初始化预览layer
    previewLayer=[AVCaptureVideoPreviewLayer layerWithSession:avCaptureSession];
    previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill;
    [scan_view.layer addSublayer:previewLayer];
    previewLayer.frame=CGRectMake(0, 0, 250, 250);

}

这里有一个超级超级超级大的坑,我找了大半天才知道原因

metaOutput.metadataObjectTypes = @[AVMetadataObjectTypeQRCode];

就是这个玩意儿,无论我怎么写,都会直接crash,说什么output不支持这种识别方式(我都删的只剩下一种了诶,同学!),结果原来是这个扫描类型的设置必须在该output被加入了session才可以设置,不然就会直接挂掉……也就是

[avCaptureSession addOutput:metaOutput];

这个之后才可以有效的设置识别类型

Please Allow Me To Control Your Camera

不过,摄像头是一个比较敏感的东西,涉及到了隐私,所以在某一代系统之后,AVFoundation要获取视频流是需要权限才行的~

switch ( [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo] )
{
    case AVAuthorizationStatusAuthorized:
    {
        // 以前就获取到权限了!
        break;
    }
    case AVAuthorizationStatusNotDetermined:
    {
        // 还不知道有没有获取到权限,我们来问一问
        [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^( BOOL granted ) {
            // 索取权限完毕,处理结果
        }];
        break;
    }
    default:
    {
        // 以前就没有获取到权限!
        break;
    }
}

这里已经注释的很清楚了,直接在适当的位置复制粘贴进去就好啦!

I got it

在前面也看到了

[metaOutput setMetadataObjectsDelegate:self queue:dispatch_get_main_queue()];

这一行设置,意思是AVCaptureMetadataOutput扫描到有效的信息之后,会通过代理的形式调用一个回调函数(方法),顺便把扫描结果给返回出来,所以这里我就让我的MainViewController遵从了AVCaptureMetadataOutputObjectsDelegate协议,并且实现了接口需要的方法

- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputMetadataObjects:(NSArray *)metadataObjects fromConnection:(AVCaptureConnection *)connection{
    if (metadataObjects.count>0) {
        for (AVMetadataObject *data in metadataObjects){
            if ([data isKindOfClass:[AVMetadataMachineReadableCodeObject class]]) {
                AVMetadataMachineReadableCodeObject *result=(AVMetadataMachineReadableCodeObject*)data;
                if (![capturedList containsObject:result.stringValue]) {
                    // 这里出现了capturedList!
                    [capturedList addObject:result.stringValue];
                    // 扫描的Raw内容就是这么简单
                    NSLog(@"scanned code is %@",result.stringValue);
                    [NetworkHelper permit:result.stringValue andSucceed:nil andFail:nil];
                }
            }
        }
    }
}

好了,现在可以说明一下,为什么要用capturedList啦~

因为,这个- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputMetadataObjects:(NSArray *)metadataObjects fromConnection:(AVCaptureConnection *)connection方法是不会中断的,是会在每个周期内都执行的(只要摄像头下有符合要求的Code),

换句话说,就是如果你的摄像头指着一个QRCode,系统就会使劲的调用这个代理方法,假设没有做任何的判断,那么很有可能对一个QRCode的处理会在一瞬间做N多次,在例子中我用到了一个网络请求,如果没有加前面的那个判断的话,服务器可能会哭~(哈哈)

End

好像只要这么多就可以把扫码功能搞定了呢!我当年为啥要偷懒去用开源库呢~

(要不然我也把这个东西整理一下,开个源,骗一些Star?)