Eisen's Blog

© 2022. All rights reserved.

使用 OwnTracks 记录每天关键地点的停留时间

2022 July-18

众所周知,手机是可以做定位的。我个人比较喜欢做一些 "自我量化" 来追溯具体的时间都去哪里了,那通勤时间和办公室停留时间这种信息自然也很想记录下来。所以在很久之前我就开始各种搜罗,想要找一个方案。但很遗憾,找了一大圈,也没找到一个让我满意的,非常失望,这里记录下之前踩的坑,然后记录下目前找到的一个还可以的方案。

使用 iOS 的关键指令(shortcuts)

shortcuts 这个东西有点像是一个手机版本的 IFTTT。它包含一些触发器,可以自动触发某些事件,然后去串联多个应用以实现一些很 fancy 的功能。常见的触发器包括:

  1. 地理位置相关的:离开、到达某个区域
  2. 时间相关:几点几分到了
  3. wifi 相关:wifi 开启、关闭、自动连接了什么局域网

等等等,具体就不细说了。

iphone short cuts

不过很显然,我可以使用那个地理位置的触发器去记录我什么时候离开了哪里、什么时候到达了哪里。比如对于我通勤的记录,那我就设置两个关键地点:家和公司,然后记录离开到达时间就行了。具体就是这么个样子:

arrive company shortcut

我是每天创建一个笔记本,然后把信息都追加到笔记本里了。

然后似乎大功告成了,可是很遗憾,实际上完全不生效,网上一搜很多相关问题,但就是没解决方案,我尝试了很多的方法都不成功。只有偶尔会成功那么一下下...我甚至咨询了在 Apple 工作的同学,最终也没有得到解决...

owntracks

iOS 自带方案不行,就只好自己写 app 了,不过很遗憾我并没有移动端开发的经验,所以这个事情就搁置了...直到我一天发现了 owntracks 这个东西。

owntracks

简单来说,owntracks 就是个不收集你个人信息,只负责将你的信息上报到你指定的服务器的一个工具。所以其实 owntracks 分为两个部分:

  1. app 使用手机的定位功能获取经纬度
  2. server 将 app 上报的信息存下来

安装配置 app

我最开始的需求很简单,就是周期性记录我的位置,然后周期性上报就好了,后续的数据处理交给服务端做好了。不过 OwnTracks 除了这个朴实的功能外,也有那种区域触发的功能。这样我就可以和 iOS 快捷指令一样关注几个地点就好了。

不过相比于 iOS 这边直接把数据记录到笔记本,我需要一个 track server 接受上报的信息。owntracks 支持两种协议:MQTT 和 HTTP,鉴于我只会 HTTP 就自己写了一个简单的 HTTP server 去接收这些信息了。然后在 app 这边配置下服务端信息就可以接收数据了。

owntracks server config

注意这里要配置到具体的某一个路径的,比如 http://112.118.221.2/pub 而不是只给个域名或者 ip 地址就完事了。

编写 track server

为啥不用官方的?

OwnTracks 有官方的 track server ,但有这么几个问题:

  1. 代码是 c 的,自己部署有点晦涩难懂,只能用 docker 部署吧,但自己修改的可能性就很小了
  2. 同时支持 MQTT 和 HTTP 而实际上我觉得对个人来说 HTTP 会更简单一些吧,那 MQTT 部分的代码就很没必要了
  3. 有一些和 Google 地图集成的功能,这些东西在国内统统没得用

OwnTracks 会以 json 的格式把所有的信息都上报到服务器,提交的具体结构可以直接 print 出来看看。这里给出我的 server 的一个片段:


import json

from flask import Flask, request


FILENAME = "data.jsonl"

app = Flask(__name__)


@app.route("/")
def hello():
    return "hello"


@app.route("/pub", methods=["POST"])
def pub():
    # print raw request data
    print(request.json)
    with open(FILENAME, "ab") as f:
        f.write(request.data)
        f.write(b"\n")
    return "done"

if __name__ == "__main__":
    print("Starting server...")
    app.run(host="0.0.0.0")

可以看到我就是把所有的数据到塞到了一个 data.jsonl 的文件里了,反正就是我自己的数据,没几条,这样就够了。

展示数据

有了这些数据,我就可以把每天的通勤给按照时间顺序展示出来了,代码如下:

@app.route("/report")
def report():
    data = []
    with open(FILENAME, "r", encoding="utf-8") as f:
        data = f.readlines()
    data = [json.loads(item) for item in data]
    data = [item for item in data if item.get("event") in ["leave", "enter"]]
    chinaTz = pytz.timezone("Asia/Shanghai")
    # datetime from unix timestamp
    data = [
        {
            "desc": item["desc"],
            "event": item["event"],
            "date": datetime.fromtimestamp(item["tst"], chinaTz).strftime(
                "%m/%d"
            ),
            "time": datetime.fromtimestamp(item["tst"], chinaTz).strftime(
                "%H:%M"
            ),
            "datetime": datetime.fromtimestamp(item["tst"], chinaTz).strftime(
                "%m/%d %H:%M"
            ),
            "tst": item["tst"]
        }
        for item in data
    ]
    # remove item with same time
    masks = [False] * len(data)
    for i in range(1, len(data)):
        if abs(data[i]["tst"] - data[i - 1]["tst"]) < 120 and \
           data[i]["desc"] == data[i - 1]["desc"]:
            masks[i - 1] = True
            masks[i] = True if data[i]["event"] != data[i - 1]["event"] else False
    data = [item for i, item in enumerate(data) if not masks[i]]
    # group by time
    grouped = OrderedDict()
    for item in data:
        if item["date"] not in grouped:
            grouped[item["date"]] = []
        grouped[item["date"]].append(item)

    result = [
        [
            date,
            "\n".join(
                [
                    f"    {item['time']}\t{item['event']}\t{item['desc']}"
                    for item in items
                ]
            ),
        ]
        for date, items in reversed(grouped.items())
    ]

    return (
        "\n".join([y for x in result for y in x]),
        200,
        {"Content-Type": "text/plain; charset=utf-8"},
    )

最终展示效果大概就是这样子:

07/18
    09:11	leave	home
    12:22	enter	gym
    13:41	leave	gym
    15:00	enter	office
07/17
    09:11	leave	home
    12:22	enter	gym
    13:41	leave	gym
    15:00	enter	office
    17:00 leave office

这个 server 的代码也放到了 owntracks-server

遇到的坑

很久之前就处理过这种坐标数据,还是有不少坑的。趁这次再次接触到也做一个记录。

ping-pang 问题

经纬度坐标貌似并不是非常稳定,会有误差,你在同一个位置可能会有不同的坐标,这就会导致你可能动都没动但是突然你就离开了一个地方然后又回到了这个地方。

对于这种情况就只能做个简单的过滤,把那些时间戳非常接近的事件直接忽略掉。当然,这种过滤也可以顺便把你路过的那种地点一并清理掉。

坐标系转换

出于一些特殊的考虑,不同的地图给的具体经纬度都是有区别的,就是说你所在的同一个位置对于不同的地图给你标记的经纬度是不同的。目前有三个主流的坐标系:

  1. WGS84 坐标系 地球坐标系,国际通用坐标系
  2. GCJ02 坐标系 火星坐标系,WGS84 坐标系加密后的坐标系;Google 国内地图、高德、QQ 地图 使用
  3. BD09 坐标系 百度坐标系,GCJ02 坐标系加密后的坐标系

在我 iphone 上我的实际位置和地图标记的位置是有偏差的,我想这应该是苹果没有考虑到中国和国外使用的坐标系是不同的,也就是说 iOS 给我获取的经纬度应该是 WGS84 的,但实际展示却用了 GCJ02 的高德地图,所以展示的位置就不对了。我猜测这也很有可能是快捷指令无法触发的原因所在。

在目前的情况下,这个问题只是对 app 本身的展示有影响,对事件触发并没有影响。可以先不管。

后续的工作

  1. 做一些基本的统计,比如通勤时间、比如办公时间
  2. 除了区域事件外也试试其他的数据,比如去自动发现停留区域,比如集成地图 api 展示具体的停留区域等
  3. 把数据直接塞进 data.jsonl 时间久了可能还是不太够,还是需要起码按照月份做个数据拆分

Java 版本从 11 更新到 17

2022 May-23

最近在实验一个用 GraphQL 支持 REST 接口的方案,需要在 Java 里写大量的 graphql 的内容,如下所示。

@RestController
@AllArgsConstructor
class GraphQLTest {
  private DgsQueryExecutor dgsQueryExecutor;

  @GetMapping("/g")
  public ResponseEntity<?> test() {
    String query =
        "query {\n" +
        " plans(category: [PERMANENT_COMPUTATION]) {\n" +
        "   id\n" +
        "   ... on TimeBoxedComputationPlan {\n" +
        "     resource {\n" +
        "       name\n" +
        "       gpu {\n" +
        "         ... on PhysicalGPU {\n" +
        "           type\n" +
        "         }\n" +
        "       }\n" +
        "     }\n" +
        "   }\n" +
        " }\n" +
        "}";
    ExecutionResult result = dgsQueryExecutor.execute(query);
    return ResponseEntity.ok(result.getData());
  }
}

可以看到这种多行拼接的 string 非常丑陋,并且没办法使用 intellij 的 inject language 这样的功能。

不过在 java 15 就已经引入了 text blocks 的语法了,并且在目前的情况下,引入这个语法确实会为后续开发提供不少便利。于是决定就直接从目前的 java 11 升级到最近的 LTS 版本 java 17 了。这里就记录下更新版本需要处理的问题。

安装 Azul Zulu Java 17

去 azul 官网安装对应的操作系统的版本

zulu java 17 for m1

安装后在命令行输入命令确认安装成功了:

$ java --version
openjdk 17.0.3 2022-04-19 LTS
OpenJDK Runtime Environment Zulu17.34+19-CA (build 17.0.3+7-LTS)
OpenJDK 64-Bit Server VM Zulu17.34+19-CA (build 17.0.3+7-LTS, mixed mode, sharing)

配置 intellij

打开 idea intellij 对配置做如下修改。

  1. 修改 gradle 使用的 jvm

gradle config jvm in intellij

  1. 修改 javac 字节码版本

change bytecode version to 17

  1. 修改启动使用的 java 版本

run configuration 1 png

run configuration 2

修改 gradle 配置

java 17 相对比较新,有些 gradle 的插件没有做很好的适配,需要做一些修改。我这里遇到了两个插件的问题,这里也记录下。

spotless 不兼容

spotless 是做代码格式化的,里面用了 googleJavaFormat 似乎有一些兼容性问题。这里没有花太多时间做调研,只是跟着 issue 做了调整。

  1. Add docs: Required export using GoogleJavaFormat on JDK 16+

根据这个 PR 将 gradle.properties 修改如下:

org.gradle.jvmargs=--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
  --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
  --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
  --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
  --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
  1. build.gradle 里更新 google java format 到最新版本
spotless {
    java {
        target project.fileTree(project.rootDir) {
            include '**/*.java'
            exclude 'build/generated/**/*.*', 'build/generated-examples/**/*.*'
        }
        toggleOffOn('@formatter:off', '@formatter:on')
+       googleJavaFormat('1.15.0')
    }
}

gradle git properties 不兼容

按照 https://github.com/n0mer/gradle-git-properties/issues/171#issuecomment-817569604gradle.properties 做修改:

org.gradle.jvmargs=--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
  --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
  --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
  --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
  --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \
+ --add-opens java.base/java.io=ALL-UNNAMED

使用 text blocks

做了如上更新后,本地跑 java 的项目就不报错了,不过还是有一些 warning 后续慢慢处理。

text blocks 就能用了,效果如下:

graphql use text blocks


记录 nvidia gpu 报错处理

2022 May-10

又发现了集群里出现了挂掉的 nvidia gpu 这里记录下如何屏蔽掉它以保证其他 GPU 可以继续被使用。

首先是监控告警,告知 nvidia-smi 命令出错了,去机器上看一下有这么个错误:

$ nvidia-smi
Unable to determine the device handle for GPU 0000:89:00.0: Unknown Error

感觉是这块卡 0000:89:00.0 出问题了。然后去执行下 dmesg 看看情况:

$ dmesg -T
[Mon May  9 20:37:33 2022] xhci_hcd 0000:89:00.2: PCI post-resume error -19!
[Mon May  9 20:37:33 2022] xhci_hcd 0000:89:00.2: HC died; cleaning up
[Mon May  9 20:37:34 2022] nvidia-gpu 0000:89:00.3: i2c timeout error ffffffff
[Mon May  9 20:37:34 2022] ucsi_ccg 6-0008: i2c_transfer failed -110

看不懂,搜了搜也有点懵逼。这台机器已经运行了挺久了,驱动也在短期内没有出过问题,那么就感觉是硬件出问题了。重启机器后,nvidia-smi 恢复了。不过如果有其他任务使用了 GPU 就又会出现这个问题了。所以考虑使用 nvidia-smi 的命令先屏蔽掉这块报错的 GPU 。

$ nvidia-smi drain  -p 0000:89:00.0 -m 1
Successfully set GPU 00000000:89:00.0 drain state to: draining.

然后再执行命令 nvidia-smi 就看不到这块卡了。