diff --git a/deploy/overlays/development/kustomization.yaml b/deploy/overlays/development/kustomization.yaml index c480097..0c9f2da 100644 --- a/deploy/overlays/development/kustomization.yaml +++ b/deploy/overlays/development/kustomization.yaml @@ -12,6 +12,7 @@ resources: - services/jetfire/ - services/optimus/ - services/ota/ + - services/virtual-vehicle/ # - services/cost/ labels: diff --git a/deploy/overlays/development/services/virtual-vehicle/deployment.yaml b/deploy/overlays/development/services/virtual-vehicle/deployment.yaml new file mode 100644 index 0000000..69dcd53 --- /dev/null +++ b/deploy/overlays/development/services/virtual-vehicle/deployment.yaml @@ -0,0 +1,41 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: virtual-vehicle + namespace: cloud-services + labels: + app: virtual-vehicle +spec: + replicas: 1 + selector: + matchLabels: + app: virtual-vehicle + template: + metadata: + labels: + app: virtual-vehicle + spec: + containers: + - name: virtual-vehicle + image: localhost:32000/virtual-vehicle:latest + imagePullPolicy: IfNotPresent + env: + - name: MANUFACTURER_URL + value: "https://dev-gw.cloud.fiskerinc.com/manufacture/manufacturer" + - name: GATEWAY_WS_URL + value: "ws://gateway.cloud-services.svc.cluster.local:8077/session" + - name: API_KEY + valueFrom: + secretKeyRef: + name: trex + key: API_KEY + - name: VIN_PREFIX + value: "MINIVIRT" + - name: SEND_INTERVAL_MS + value: "1000" + resources: + requests: + cpu: 50m + memory: 32Mi + limits: + memory: 64Mi diff --git a/deploy/overlays/development/services/virtual-vehicle/kustomization.yaml b/deploy/overlays/development/services/virtual-vehicle/kustomization.yaml new file mode 100644 index 0000000..2898c11 --- /dev/null +++ b/deploy/overlays/development/services/virtual-vehicle/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - deployment.yaml + - secret.yaml diff --git a/deploy/overlays/development/services/virtual-vehicle/secret.yaml b/deploy/overlays/development/services/virtual-vehicle/secret.yaml new file mode 100644 index 0000000..291b848 --- /dev/null +++ b/deploy/overlays/development/services/virtual-vehicle/secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: trex + namespace: cloud-services +type: Opaque +stringData: + API_KEY: "349bf86d-7249-4c76-82aa-db56782703f5" diff --git a/go.work b/go.work index d5f35a9..ca31a41 100644 --- a/go.work +++ b/go.work @@ -10,4 +10,5 @@ use ( ./services/jetfire ./services/optimus ./services/ota_update_go + ./services/virtual-vehicle ) diff --git a/services/virtual-vehicle/README.md b/services/virtual-vehicle/README.md new file mode 100644 index 0000000..07ff921 --- /dev/null +++ b/services/virtual-vehicle/README.md @@ -0,0 +1,35 @@ +# Virtual Vehicle Simulator + +A lightweight vehicle simulator that generates CAN telemetry and sends it to the cloud gateway. + +## Overview + +This service simulates a connected vehicle (T.Rex) by: +1. Registering with the manufacturer API to get certificates +2. Connecting to the gateway via WebSocket with mTLS +3. Generating and sending CAN frames + +## Configuration + +| Env Var | Description | Default | +|---------|-------------|---------| +| MANUFACTURER_URL | URL for vehicle registration | http://gateway:8077/manufacture/manufacturer | +| GATEWAY_WS_URL | WebSocket URL for gateway | ws://gateway:8077/session | +| API_KEY | API key for manufacturer endpoint | - | +| VIN_PREFIX | Prefix for generated VIN | VIRTUAL | +| SEND_INTERVAL_MS | Telemetry interval in ms | 1000 | + +## CAN Frame IDs + +| CAN ID | Description | +|--------|-------------| +| 0x100 | Speed | +| 0x101 | RPM | +| 0x102 | Battery SOC | +| 0x103 | Battery voltage | +| 0x104 | Temperature | +| 0x105 | GPS latitude | +| 0x106 | GPS longitude | +| 0x200 | Door status | +| 0x201 | Light status | +| 0x300 | HVAC | diff --git a/services/virtual-vehicle/go.mod b/services/virtual-vehicle/go.mod new file mode 100644 index 0000000..f78aaa2 --- /dev/null +++ b/services/virtual-vehicle/go.mod @@ -0,0 +1,22 @@ +module github.com/fiskerinc/cloud-services/services/virtual-vehicle + +go 1.25 + +require ( + github.com/fiskerinc/cloud-services/pkg v0.0.0 + github.com/gobwas/ws v1.4.0 +) + +require ( + github.com/ReneKroon/ttlcache/v2 v2.11.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rs/zerolog v1.29.1 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect +) + +replace github.com/fiskerinc/cloud-services/pkg => ../../pkg diff --git a/services/virtual-vehicle/go.sum b/services/virtual-vehicle/go.sum new file mode 100644 index 0000000..57311ce --- /dev/null +++ b/services/virtual-vehicle/go.sum @@ -0,0 +1,91 @@ +github.com/ReneKroon/ttlcache/v2 v2.11.0 h1:OvlcYFYi941SBN3v9dsDcC2N8vRxyHcCmJb3Vl4QMoM= +github.com/ReneKroon/ttlcache/v2 v2.11.0/go.mod h1:mBxvsNY+BT8qLLd6CuAJubbKo6r0jh3nb5et22bbfGY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= +github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/virtual-vehicle/main.go b/services/virtual-vehicle/main.go new file mode 100644 index 0000000..96c6cb1 --- /dev/null +++ b/services/virtual-vehicle/main.go @@ -0,0 +1,283 @@ +package main + +import ( + "bytes" + "compress/flate" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/fiskerinc/cloud-services/pkg/logger" + "github.com/fiskerinc/cloud-services/pkg/utils/app" + "github.com/fiskerinc/cloud-services/pkg/utils/envtool" + "github.com/gobwas/ws" + "github.com/gobwas/ws/wsflate" +) + +var ( + manufacturerURL = envtool.GetEnv("MANUFACTURER_URL", "http://gateway.cloud-services.svc.cluster.local:8077/manufacture/manufacturer") + gatewayWSURL = envtool.GetEnv("GATEWAY_WS_URL", "ws://gateway.cloud-services.svc.cluster.local:8077/session") + apiKey = envtool.GetEnv("API_KEY", "") + vinPrefix = envtool.GetEnv("VIN_PREFIX", "VIRTUAL") + sendInterval = envtool.GetEnvInt("SEND_INTERVAL_MS", 1000) +) + +func main() { + app.Setup("virtual-vehicle", func() {}) + + // Generate random VIN + vin := generateVIN(vinPrefix) + logger.Info().Str("vin", vin).Msg("Generated VIN") + + // Register vehicle and get certificates + cert, key, err := registerVehicle(vin) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to register vehicle") + } + logger.Info().Str("vin", vin).Msg("Vehicle registered successfully") + + // Load TLS certificate + tlsCert, err := tls.X509KeyPair([]byte(cert), []byte(key)) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to load TLS certificate") + } + + // Connect to gateway + conn, err := connectToGateway(vin, tlsCert) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to connect to gateway") + } + defer conn.Close() + logger.Info().Str("vin", vin).Msg("Connected to gateway") + + // Handle shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Start sending telemetry + ticker := time.NewTicker(time.Duration(sendInterval) * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := sendTelemetry(conn, vin); err != nil { + logger.Error().Err(err).Msg("Failed to send telemetry") + return + } + case <-sigChan: + logger.Info().Msg("Shutting down") + return + } + } +} + +type ManufacturerResponse struct { + Certificates []Certificate `json:"certificates"` +} + +type Certificate struct { + Type string `json:"type"` + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key"` +} + +func registerVehicle(vin string) (cert, key string, err error) { + payload := map[string]string{"vin": vin} + body, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", manufacturerURL, bytes.NewReader(body)) + if err != nil { + return "", "", err + } + req.Header.Set("Content-Type", "application/json") + if apiKey != "" { + req.Header.Set("Api-Key", apiKey) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return "", "", fmt.Errorf("manufacturer API returned %d: %s", resp.StatusCode, string(body)) + } + + var mfgResp ManufacturerResponse + if err := json.NewDecoder(resp.Body).Decode(&mfgResp); err != nil { + return "", "", err + } + + for _, c := range mfgResp.Certificates { + if c.Type == "TBOX" { + return c.PublicKey, c.PrivateKey, nil + } + } + + return "", "", fmt.Errorf("no TBOX certificate found") +} + +func connectToGateway(vin string, tlsCert tls.Certificate) (*wsConn, error) { + dialer := ws.Dialer{ + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + InsecureSkipVerify: true, + }, + Header: ws.HandshakeHeaderHTTP(http.Header{ + "User-Agent": []string{fmt.Sprintf("Fisker Ocean T.Rex 1.0.0 %s", vin)}, + }), + } + + conn, _, _, err := dialer.Dial(nil, gatewayWSURL) + if err != nil { + return nil, err + } + + return &wsConn{conn: conn}, nil +} + +type wsConn struct { + conn net.Conn + writer *wsflate.Writer +} + +func (w *wsConn) Close() error { + return w.conn.Close() +} + +func (w *wsConn) Write(data []byte) error { + // Compress the data + var buf bytes.Buffer + fw, _ := flate.NewWriter(&buf, flate.BestSpeed) + fw.Write(data) + fw.Close() + + frame := ws.NewFrame(ws.OpText, true, buf.Bytes()) + frame.Header.Rsv = ws.Rsv(true, false, false) // Set compression bit + return ws.WriteFrame(w.conn, frame) +} + +type TelemetryMessage struct { + Handler string `json:"handler"` + Data json.RawMessage `json:"data"` +} + +type CANFrame struct { + ID uint32 `json:"id"` + Value []byte `json:"value"` +} + +type CANData struct { + Frames []CANFrame `json:"frames"` +} + +func sendTelemetry(conn *wsConn, vin string) error { + // Generate fake CAN frames + frames := generateFakeCANFrames() + + canData := CANData{Frames: frames} + canDataBytes, _ := json.Marshal(canData) + + msg := TelemetryMessage{ + Handler: "can", + Data: canDataBytes, + } + + msgBytes, err := json.Marshal(msg) + if err != nil { + return err + } + + logger.Debug().Str("vin", vin).Int("frames", len(frames)).Msg("Sending telemetry") + return conn.Write(msgBytes) +} + +func generateFakeCANFrames() []CANFrame { + // Generate 10-50 random CAN frames + numFrames := rand.Intn(40) + 10 + frames := make([]CANFrame, numFrames) + + // Common CAN IDs for vehicle telemetry + canIDs := []uint32{ + 0x100, // Speed + 0x101, // RPM + 0x102, // Battery SOC + 0x103, // Battery voltage + 0x104, // Temperature + 0x105, // GPS lat + 0x106, // GPS lon + 0x200, // Door status + 0x201, // Light status + 0x300, // HVAC + } + + for i := 0; i < numFrames; i++ { + frames[i] = CANFrame{ + ID: canIDs[rand.Intn(len(canIDs))], + Value: make([]byte, 8), + } + rand.Read(frames[i].Value) + } + + return frames +} + +func generateVIN(prefix string) string { + // Pad prefix to 8 chars + for len(prefix) < 8 { + prefix += "X" + } + if len(prefix) > 8 { + prefix = prefix[:8] + } + + // Generate remaining 9 characters (position 9 is check digit, calculated later) + chars := "ABCDEFGHJKLMNPRSTUVWXYZ0123456789" + suffix := make([]byte, 8) + for i := range suffix { + suffix[i] = chars[rand.Intn(len(chars))] + } + + vin := prefix + "X" + string(suffix) // X is placeholder for check digit + + // Calculate check digit + checkDigit := calculateCheckDigit(vin) + vin = vin[:8] + string(checkDigit) + vin[9:] + + return vin +} + +func calculateCheckDigit(vin string) byte { + weights := []int{8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2} + values := map[byte]int{ + 'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5, 'F': 6, 'G': 7, 'H': 8, + 'J': 1, 'K': 2, 'L': 3, 'M': 4, 'N': 5, 'P': 7, 'R': 9, + 'S': 2, 'T': 3, 'U': 4, 'V': 5, 'W': 6, 'X': 7, 'Y': 8, 'Z': 9, + '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, + } + + sum := 0 + for i, c := range vin { + if v, ok := values[byte(c)]; ok { + sum += v * weights[i] + } + } + + remainder := sum % 11 + if remainder == 10 { + return 'X' + } + return byte('0' + remainder) +}