├── .env
├── .gitignore
├── Dockerfile
├── README.md
├── cmd
└── yapi.go
├── go.mod
├── go.sum
├── internal
├── env
│ └── env.go
├── glagol
│ ├── client.go
│ ├── client_test.go
│ ├── device.go
│ └── device_test.go
├── server
│ └── server.go
└── socket
│ ├── cert.go
│ ├── cert_test.go
│ ├── contract.go
│ ├── conversation.go
│ ├── conversation_test.go
│ └── socket.go
├── pkg
└── mdns
│ ├── mdns.go
│ └── mdns_test.go
├── yapi
├── yapi.service
└── yapi_arm
/.env:
--------------------------------------------------------------------------------
1 | OAUTH_TOKEN=
2 | DEVICE_ID=
3 | HTTP_HOST=:8001
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .vscode
3 | vendor
4 | .env.local
5 | db
6 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | #--------------------------------------
2 | FROM golang:1.18.1-alpine3.14 as build
3 |
4 | RUN mkdir /app
5 | WORKDIR /app
6 |
7 | COPY . .
8 |
9 | RUN go mod download
10 | RUN go build -o /app/yapi /app/cmd/yapi.go
11 |
12 | #--------------------------------------
13 | FROM alpine:3.14 as app
14 |
15 | WORKDIR /app
16 |
17 | COPY --from=build /app/yapi /app/yapi
18 | COPY --from=build /app/.env.local /app/.env.local
19 |
20 | RUN chmod +x /app/yapi
21 |
22 | EXPOSE 8001
23 |
24 | ENTRYPOINT /app/yapi
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
API для управления Яндекс станцией
2 |
3 | `yapi` собран под AMD \
4 | `env GOOS=linux GOARCH=amd64 go build -o yapi cmd/yapi.go`
5 |
6 | .env.local
7 |
8 | OAUTH_TOKEN - токен Яндекса \
9 | DEVICE_ID - приложение яндекс -> устройства -> Станция -> идентификатор устройства \
10 | HTTP_HOST - хост http сервера (по-умолчанию `localhost:8001`)
11 |
12 | Установка
13 |
14 | `cd /opt` \
15 | `git clone https://github.com/ebuyan/yapi.git` \
16 | `cp .env .env.local` \
17 | `mkdir -p /var/log/yapi` \
18 | `touch /var/log/yapi/app.log` \
19 | `cp yapi.service /etc/systemd/systemd` \
20 | `systemctl daemon-reload` \
21 | `systemctl start yapi.service` \
22 | `systemctl enable yapi.service`
23 |
24 | API
25 |
26 | - Статус Станции \
27 | `GET localhost:8001`
28 | ```json
29 | {
30 | "state":{
31 | "playerState":{
32 | "duration":853,
33 | "extra":{
34 | "coverURI":""
35 | },
36 | "hasPause":true,
37 | "hasPlay":false,
38 | "progress":811,
39 | "subtitle":"Исполнитель",
40 | "title":"Песня"
41 | },
42 | "playing":false,
43 | "volume":0.5
44 | }
45 | }
46 | ```
47 | - Перемотка \
48 | `POST localhost:8001`
49 | ```json
50 | {
51 | "command": "rewind",
52 | "position" : 120
53 | }
54 | ```
55 | - Продолжить \
56 | `POST localhost:8001`
57 | ```json
58 | {
59 | "command": "play"
60 | }
61 | ```
62 | - Пауза \
63 | `POST localhost:8001`
64 | ```json
65 | {
66 | "command": "stop"
67 | }
68 | ```
69 | - Следующий \
70 | `POST localhost:8001`
71 | ```json
72 | {
73 | "command": "next"
74 | }
75 | ```
76 | - Предыдущий \
77 | `POST localhost:8001`
78 | ```json
79 | {
80 | "command": "prev"
81 | }
82 | ```
83 | - Изменить громкость \
84 | `POST localhost:8001`
85 | ```json
86 | {
87 | "command" : "setVolume",
88 | "volume" : 0.5
89 | }
90 | ```
91 | - Выполнить команду \
92 | `POST localhost:8001`
93 | ```json
94 | {
95 | "command" : "sendText",
96 | "text" : "Включи музыку"
97 | }
98 | ```
99 | - Воспроизвести текст \
100 | `POST localhost:8001`
101 | ```json
102 | {
103 | "command" : "sendText",
104 | "text" : "Повтори за мной 'Повторяю'"
105 | }
106 | ```
107 |
--------------------------------------------------------------------------------
/cmd/yapi.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "log"
6 | "yapi/internal/env"
7 | "yapi/internal/glagol"
8 | "yapi/internal/server"
9 | "yapi/internal/socket"
10 | "yapi/pkg/mdns"
11 | )
12 |
13 | func main() {
14 | entry, err := mdns.Discover(env.Config.DeviceId, mdns.YandexServicePrefix)
15 | if err != nil {
16 | log.Fatalln(err)
17 | }
18 |
19 | client := glagol.NewClient(env.Config.GlagolUrl, env.Config.DeviceId, env.Config.OAuthToken)
20 | station, err := client.GetDevice(context.Background(), entry.IpAddr, entry.Port)
21 | if err != nil {
22 | log.Fatalln(err)
23 | }
24 |
25 | conversation := socket.NewConversation(station)
26 | soc := socket.NewSocket(conversation)
27 |
28 | if err = soc.Run(context.Background()); err != nil {
29 | log.Fatalln(err)
30 | }
31 |
32 | srv := server.NewHttp(soc, env.Config.HttpHost)
33 | if err = srv.Start(); err != nil {
34 | log.Fatalln(err)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module yapi
2 |
3 | go 1.21
4 |
5 | require (
6 | github.com/google/uuid v1.6.0
7 | github.com/gorilla/mux v1.8.1
8 | github.com/gorilla/websocket v1.5.1
9 | github.com/hashicorp/mdns v1.0.5
10 | github.com/joho/godotenv v1.5.1
11 | github.com/stretchr/testify v1.8.4
12 | )
13 |
14 | require (
15 | github.com/davecgh/go-spew v1.1.1 // indirect
16 | github.com/miekg/dns v1.1.58 // indirect
17 | github.com/pmezard/go-difflib v1.0.0 // indirect
18 | golang.org/x/mod v0.15.0 // indirect
19 | golang.org/x/net v0.20.0 // indirect
20 | golang.org/x/sys v0.16.0 // indirect
21 | golang.org/x/tools v0.17.0 // indirect
22 | gopkg.in/yaml.v3 v3.0.1 // indirect
23 | )
24 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
5 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
6 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
7 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
8 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
9 | github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE=
10 | github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
11 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
12 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
13 | github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
14 | github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
15 | github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
18 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
19 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
20 | golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8=
21 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
22 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
23 | golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
24 | golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
25 | golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
26 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
27 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
28 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
29 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
30 | golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
31 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
32 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
33 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
34 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
35 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
36 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
37 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
38 | golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
39 | golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
42 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
43 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
44 |
--------------------------------------------------------------------------------
/internal/env/env.go:
--------------------------------------------------------------------------------
1 | package env
2 |
3 | import (
4 | "github.com/joho/godotenv"
5 | "log"
6 | "os"
7 | )
8 |
9 | type Env struct {
10 | OAuthToken string
11 | DeviceId string
12 | HttpHost string
13 | GlagolUrl string
14 | }
15 |
16 | var Config Env
17 |
18 | func init() {
19 | if err := godotenv.Load(".env.local"); err != nil {
20 | log.Fatalln("no .env.local file")
21 | }
22 | Config.OAuthToken = os.Getenv("OAUTH_TOKEN")
23 | Config.DeviceId = os.Getenv("DEVICE_ID")
24 | Config.HttpHost = os.Getenv("HTTP_HOST")
25 | Config.GlagolUrl = os.Getenv("GLAGOL_URL")
26 | }
27 |
--------------------------------------------------------------------------------
/internal/glagol/client.go:
--------------------------------------------------------------------------------
1 | package glagol
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "io/ioutil"
9 | "net/http"
10 | "time"
11 | )
12 |
13 | const (
14 | defaultUrl = "https://quasar.yandex.net/glagol"
15 | deviceListAction = "device_list"
16 | tokenAction = "token"
17 | )
18 |
19 | type Client struct {
20 | deviceId string
21 | token string
22 | baseUrl string
23 | client *http.Client
24 | }
25 |
26 | func NewClient(baseUrl, deviceId, token string) Client {
27 | if baseUrl == "" {
28 | baseUrl = defaultUrl
29 | }
30 | return Client{
31 | deviceId: deviceId,
32 | token: token,
33 | baseUrl: baseUrl,
34 | client: &http.Client{
35 | Timeout: time.Second * 10,
36 | },
37 | }
38 | }
39 |
40 | func (g *Client) GetDevice(ctx context.Context, ipAddr, port string) (device *Device, err error) {
41 | devices, err := g.getDeviceList(ctx)
42 | if err != nil {
43 | return
44 | }
45 |
46 | deviceResp, err := g.discoverDevices(devices)
47 | if err != nil {
48 | return
49 | }
50 |
51 | device = NewDevice(deviceResp.Id, deviceResp.Platform, deviceResp.Glagol.Security.ServerCertificate)
52 | device.SetHost(ipAddr, port)
53 | device.SetRefreshTokenHandler(g.getJwtTokenForDevice)
54 | if err = device.RefreshToken(ctx); err != nil {
55 | return nil, err
56 | }
57 | return device, nil
58 | }
59 |
60 | func (g *Client) getDeviceList(ctx context.Context) ([]DeviceResponse, error) {
61 | responseBody, err := g.sendRequest(ctx, deviceListAction)
62 | if err != nil {
63 | return nil, err
64 | }
65 |
66 | var response DeviceListResponse
67 | _ = json.Unmarshal(responseBody, &response)
68 | list := response.Devices
69 | if len(list) == 0 {
70 | return nil, errors.New("no devices found at account")
71 | }
72 | return list, nil
73 | }
74 |
75 | func (g *Client) discoverDevices(devices []DeviceResponse) (device DeviceResponse, err error) {
76 | for _, device = range devices {
77 | if device.Id == g.deviceId {
78 | return
79 | }
80 | }
81 | return device, errors.New("no station found in local network")
82 | }
83 |
84 | func (g *Client) getJwtTokenForDevice(ctx context.Context, deviceId, platform string) (token string, err error) {
85 | responseBody, err := g.sendRequest(ctx, fmt.Sprintf("%s?device_id=%s&platform=%s", tokenAction, deviceId, platform))
86 | if err != nil {
87 | return
88 | }
89 | response := TokenResponse{}
90 | _ = json.Unmarshal(responseBody, &response)
91 | return response.Token, nil
92 | }
93 |
94 | func (g *Client) sendRequest(ctx context.Context, endPoint string) (response []byte, err error) {
95 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.baseUrl+"/"+endPoint, http.NoBody)
96 | req.Header.Set("Authorization", "OAuth "+g.token)
97 | req.Header.Set("Content-Type", "application/json")
98 |
99 | resp, err := g.client.Do(req)
100 | if err != nil {
101 | return
102 | }
103 |
104 | if resp.StatusCode > http.StatusOK {
105 | return nil, errors.New(fmt.Sprintf("bad response %d", resp.StatusCode))
106 | }
107 |
108 | defer func() { _ = resp.Body.Close() }()
109 | response, err = ioutil.ReadAll(resp.Body)
110 | return
111 | }
112 |
113 | type DeviceListResponse struct {
114 | Devices []DeviceResponse `json:"devices"`
115 | }
116 |
117 | type DeviceResponse struct {
118 | Id string `json:"id"`
119 | Platform string `json:"platform"`
120 | Glagol struct {
121 | Security struct {
122 | ServerCertificate string `json:"server_certificate"`
123 | ServerPrivateKey string `json:"server_private_key"`
124 | } `json:"security"`
125 | } `json:"glagol"`
126 | }
127 |
128 | type TokenResponse struct {
129 | Token string `json:"token"`
130 | }
131 |
--------------------------------------------------------------------------------
/internal/glagol/client_test.go:
--------------------------------------------------------------------------------
1 | package glagol
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "github.com/stretchr/testify/require"
8 | "net/http"
9 | "net/http/httptest"
10 | "testing"
11 | )
12 |
13 | func TestClient_IfEmptyBaseUrl(t *testing.T) {
14 | cli := NewClient("", "", "")
15 | require.NotEmpty(t, cli.baseUrl)
16 | }
17 |
18 | func TestClient_GetDevice(t *testing.T) {
19 | srv := mockSrv(getNotEmptyMockResponse(), http.StatusOK)
20 | defer srv.Close()
21 |
22 | cli := NewClient(srv.URL, "id", "token")
23 | actual, err := cli.GetDevice(context.Background(), "", "")
24 | expected := NewDevice("id", "", "")
25 |
26 | require.Nil(t, err)
27 | require.Equal(t, expected.GetId(), actual.GetId())
28 | }
29 |
30 | func TestClient_GetDeviceGlagolIsError(t *testing.T) {
31 | srv := mockSrv(getNotEmptyMockResponse(), http.StatusInternalServerError)
32 | defer srv.Close()
33 |
34 | cli := NewClient(srv.URL, "unknown", "")
35 | actual, err := cli.GetDevice(context.Background(), "", "")
36 |
37 | require.NotNil(t, err)
38 | require.Nil(t, actual)
39 | }
40 |
41 | func TestClient_GetDeviceWhenDeviceNotMatched(t *testing.T) {
42 | srv := mockSrv(getNotEmptyMockResponse(), http.StatusOK)
43 | defer srv.Close()
44 |
45 | cli := NewClient(srv.URL, "unknown", "")
46 | actual, err := cli.GetDevice(context.Background(), "", "")
47 |
48 | require.NotNil(t, err)
49 | require.Nil(t, actual)
50 | }
51 |
52 | func TestClient_GetDeviceWhenDeviceNotFound(t *testing.T) {
53 | srv := mockSrv(DeviceListResponse{}, http.StatusOK)
54 | defer srv.Close()
55 |
56 | cli := NewClient(srv.URL, "", "")
57 | actual, err := cli.GetDevice(context.Background(), "", "")
58 |
59 | require.NotNil(t, err)
60 | require.Nil(t, actual)
61 | }
62 |
63 | func mockSrv(mockResp DeviceListResponse, statusCode int) *httptest.Server {
64 | r := http.NewServeMux()
65 | r.HandleFunc(fmt.Sprintf("/%s", deviceListAction), func(w http.ResponseWriter, r *http.Request) {
66 | w.WriteHeader(statusCode)
67 | data, _ := json.Marshal(mockResp)
68 | _, _ = w.Write(data)
69 | })
70 | r.HandleFunc(fmt.Sprintf("/%s", tokenAction), func(w http.ResponseWriter, r *http.Request) {
71 | body := TokenResponse{Token: "test"}
72 | data, _ := json.Marshal(body)
73 | _, _ = w.Write(data)
74 | })
75 | return httptest.NewServer(r)
76 | }
77 |
78 | func getNotEmptyMockResponse() DeviceListResponse {
79 | device := DeviceResponse{Id: "id", Platform: "platform"}
80 | device.Glagol.Security.ServerCertificate = "cert"
81 | device.Glagol.Security.ServerPrivateKey = "key"
82 |
83 | return DeviceListResponse{
84 | []DeviceResponse{device},
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/internal/glagol/device.go:
--------------------------------------------------------------------------------
1 | package glagol
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "net/url"
7 | "sync"
8 | )
9 |
10 | type Device struct {
11 | id string
12 | platform string
13 | certificate string
14 | token string
15 | host string
16 |
17 | mu sync.RWMutex
18 | state DeviceState
19 |
20 | refreshTokenHandler func(ctx context.Context, deviceId, platform string) (string, error)
21 | }
22 |
23 | func NewDevice(deviceId, platform, certificate string) *Device {
24 | return &Device{id: deviceId, platform: platform, certificate: certificate}
25 | }
26 |
27 | func (d *Device) GetId() string {
28 | return d.id
29 | }
30 |
31 | func (d *Device) GetState() []byte {
32 | d.mu.RLock()
33 | js, _ := json.Marshal(d.state)
34 | d.mu.RUnlock()
35 | return js
36 | }
37 |
38 | func (d *Device) SetState(state []byte) {
39 | d.mu.Lock()
40 | _ = json.Unmarshal(state, &d.state)
41 | d.mu.Unlock()
42 | }
43 |
44 | func (d *Device) GetHost() string {
45 | return d.host
46 | }
47 |
48 | func (d *Device) SetHost(ipAddr, port string) {
49 | host := url.URL{Scheme: "wss", Host: ipAddr + ":" + port, Path: "/"}
50 | d.host = host.String()
51 | }
52 |
53 | func (d *Device) SetRefreshTokenHandler(handler func(ctx context.Context, deviceId, platform string) (string, error)) {
54 | d.refreshTokenHandler = handler
55 | }
56 |
57 | func (d *Device) RefreshToken(ctx context.Context) (err error) {
58 | d.token, err = d.refreshTokenHandler(ctx, d.id, d.platform)
59 | return err
60 | }
61 |
62 | func (d *Device) GetToken() string {
63 | return d.token
64 | }
65 |
66 | func (d *Device) GetCertificate() string {
67 | return d.certificate
68 | }
69 |
70 | type DeviceState struct {
71 | State struct {
72 | PlayerState struct {
73 | Duration float64 `json:"duration"`
74 | HasPause bool `json:"hasPause"`
75 | HasPlay bool `json:"hasPlay"`
76 | Progress float64 `json:"progress"`
77 | Subtitle string `json:"subtitle"`
78 | Title string `json:"title"`
79 | Extra struct {
80 | CoverURI string `json:"coverURI"`
81 | StateType string `json:"stateType"`
82 | } `json:"extra"`
83 | } `json:"playerState"`
84 | Playing bool `json:"playing"`
85 | Volume float64 `json:"volume"`
86 | } `json:"state"`
87 | }
88 |
--------------------------------------------------------------------------------
/internal/glagol/device_test.go:
--------------------------------------------------------------------------------
1 | package glagol
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "github.com/stretchr/testify/require"
7 | "testing"
8 | )
9 |
10 | func TestDevice_GetId(t *testing.T) {
11 | d := getDevice()
12 | require.Equal(t, "id", d.GetId())
13 | }
14 |
15 | func TestDevice_GetCertificate(t *testing.T) {
16 | d := getDevice()
17 | require.Equal(t, "cert", d.GetCertificate())
18 | }
19 |
20 | func TestDevice_SetHost(t *testing.T) {
21 | d := getDevice()
22 | d.SetHost("127.0.0.1", "8080")
23 | require.Equal(t, "wss://127.0.0.1:8080/", d.GetHost())
24 | }
25 |
26 | func TestDevice_RefreshToken(t *testing.T) {
27 | d := getDevice()
28 | d.SetRefreshTokenHandler(func(ctx context.Context, deviceId, platform string) (string, error) {
29 | return "test_token", nil
30 | })
31 | err := d.RefreshToken(context.Background())
32 | require.Nil(t, err)
33 | require.Equal(t, "test_token", d.GetToken())
34 | }
35 |
36 | func TestDevice_SetState(t *testing.T) {
37 | state := DeviceState{}
38 | expected, _ := json.Marshal(&state)
39 |
40 | d := getDevice()
41 | d.SetState(expected)
42 |
43 | require.Equal(t, expected, d.GetState())
44 | }
45 |
46 | func getDevice() *Device {
47 | return NewDevice("id", "platform", "cert")
48 | }
49 |
--------------------------------------------------------------------------------
/internal/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 | "time"
6 | "yapi/internal/socket"
7 |
8 | "github.com/gorilla/mux"
9 | )
10 |
11 | type Http struct {
12 | addr string
13 | socket socket.Socket
14 | }
15 |
16 | func NewHttp(s socket.Socket, addr string) Http {
17 | return Http{addr, s}
18 | }
19 |
20 | func (h *Http) Start() error {
21 | r := mux.NewRouter()
22 | r.HandleFunc("/", h.socket.Write).Methods("POST")
23 | r.HandleFunc("/", h.socket.Read).Methods("GET")
24 | http.Handle("/", r)
25 |
26 | srv := &http.Server{
27 | Addr: h.addr,
28 | WriteTimeout: time.Second * 15,
29 | ReadTimeout: time.Second * 15,
30 | IdleTimeout: time.Second * 60,
31 | Handler: r,
32 | }
33 |
34 | return srv.ListenAndServe()
35 | }
36 |
--------------------------------------------------------------------------------
/internal/socket/cert.go:
--------------------------------------------------------------------------------
1 | package socket
2 |
3 | import (
4 | "crypto/x509"
5 | "encoding/pem"
6 | "errors"
7 | )
8 |
9 | func GetCerts(certificate string) (certs *x509.CertPool, err error) {
10 | certs = x509.NewCertPool()
11 | block, _ := pem.Decode([]byte(certificate))
12 | if block == nil {
13 | err = errors.New("failed to parse certificate PEM")
14 | return
15 | }
16 | cert, err := x509.ParseCertificate(block.Bytes)
17 | if err != nil {
18 | return
19 | }
20 | certs.AddCert(cert)
21 | return
22 | }
23 |
--------------------------------------------------------------------------------
/internal/socket/cert_test.go:
--------------------------------------------------------------------------------
1 | package socket
2 |
3 | import (
4 | "github.com/stretchr/testify/require"
5 | "testing"
6 | )
7 |
8 | const testCert = `
9 | -----BEGIN TESTING KEY-----
10 | MIICDjCCAbUCCQDF6SfN0nsnrjAJBgcqhkjOPQQBMIGPMQswCQYDVQQGEwJVUzET
11 | MBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEVMBMG
12 | A1UECgwMR29vZ2xlLCBJbmMuMRcwFQYDVQQDDA53d3cuZ29vZ2xlLmNvbTEjMCEG
13 | CSqGSIb3DQEJARYUZ29sYW5nLWRldkBnbWFpbC5jb20wHhcNMTIwNTIwMjAyMDUw
14 | WhcNMjIwNTE4MjAyMDUwWjCBjzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlm
15 | b3JuaWExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFTATBgNVBAoMDEdvb2dsZSwg
16 | SW5jLjEXMBUGA1UEAwwOd3d3Lmdvb2dsZS5jb20xIzAhBgkqhkiG9w0BCQEWFGdv
17 | bGFuZy1kZXZAZ21haWwuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/Wgn
18 | WQDo5+bz71T0327ERgd5SDDXFbXLpzIZDXTkjpe8QTEbsF+ezsQfrekrpDPC4Cd3
19 | P9LY0tG+aI8IyVKdUjAJBgcqhkjOPQQBA0gAMEUCIGlsqMcRqWVIWTD6wXwe6Jk2
20 | DKxL46r/FLgJYnzBEH99AiEA3fBouObsvV1R3oVkb4BQYnD4/4LeId6lAT43YvyV
21 | a/A=
22 | -----END TESTING KEY-----`
23 |
24 | func TestGetCerts(t *testing.T) {
25 | _, err := GetCerts(testCert)
26 | require.Nil(t, err)
27 | }
28 |
--------------------------------------------------------------------------------
/internal/socket/contract.go:
--------------------------------------------------------------------------------
1 | package socket
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | )
7 |
8 | type Socket interface {
9 | Run(ctx context.Context) (err error)
10 | Read(w http.ResponseWriter, r *http.Request)
11 | Write(w http.ResponseWriter, r *http.Request)
12 | }
13 |
14 | type Device interface {
15 | GetState() []byte
16 | SetState(state []byte)
17 | GetHost() string
18 | RefreshToken(ctx context.Context) error
19 | GetToken() string
20 | GetCertificate() string
21 | }
22 |
--------------------------------------------------------------------------------
/internal/socket/conversation.go:
--------------------------------------------------------------------------------
1 | package socket
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "errors"
7 | "fmt"
8 | "log"
9 | "os"
10 | "os/signal"
11 | "time"
12 |
13 | "github.com/google/uuid"
14 | "github.com/gorilla/websocket"
15 | )
16 |
17 | const (
18 | writeWait = 10 * time.Second
19 | pongWait = 60 * time.Second
20 | pingWait = (pongWait * 9) / 10
21 | )
22 |
23 | type Conversation struct {
24 | device Device
25 | connection *websocket.Conn
26 | error chan string
27 | connected bool
28 | }
29 |
30 | func NewConversation(device Device) *Conversation {
31 | return &Conversation{
32 | device: device,
33 | error: make(chan string, 1),
34 | }
35 | }
36 |
37 | func (c *Conversation) Connect(ctx context.Context) (err error) {
38 | dialer := websocket.DefaultDialer
39 | certs, err := GetCerts(c.device.GetCertificate())
40 | if err != nil {
41 | return
42 | }
43 |
44 | dialer.TLSClientConfig = &tls.Config{
45 | RootCAs: certs,
46 | InsecureSkipVerify: true,
47 | }
48 |
49 | if c.connection, _, err = dialer.DialContext(ctx, c.device.GetHost(), nil); err != nil {
50 | return
51 | }
52 | if err = c.pingDevice(); err == nil {
53 | log.Println("successful connection to the station")
54 | }
55 | c.connected = true
56 | return
57 | }
58 |
59 | func (c *Conversation) Run(ctx context.Context) error {
60 | go c.read(ctx)
61 | go c.pingConn(ctx)
62 | go c.refreshToken(ctx)
63 |
64 | interrupt := make(chan os.Signal, 1)
65 | signal.Notify(interrupt, os.Interrupt)
66 |
67 | select {
68 | case e := <-c.error:
69 | return errors.New(e)
70 | case <-interrupt:
71 | return errors.New("interrupt")
72 | }
73 | }
74 |
75 | func (c *Conversation) ReadFromDevice() []byte {
76 | return c.device.GetState()
77 | }
78 |
79 | func (c *Conversation) SendToDevice(msg Payload) error {
80 | message := DeviceRequest{
81 | ConversationToken: c.device.GetToken(),
82 | Id: uuid.New().String(),
83 | SentTime: time.Now().UnixNano(),
84 | Payload: msg,
85 | }
86 | _ = c.connection.SetWriteDeadline(time.Now().Add(writeWait))
87 | if err := c.connection.WriteJSON(message); err != nil {
88 | c.error <- "write error: " + err.Error()
89 | return err
90 | }
91 | return nil
92 | }
93 |
94 | func (c *Conversation) Close() {
95 | _ = c.connection.Close()
96 | c.connected = false
97 | close(c.error)
98 | log.Println("connection closed")
99 | }
100 |
101 | func (c *Conversation) read(ctx context.Context) {
102 | log.Println("start read socket")
103 |
104 | for c.connected {
105 | select {
106 | case <-ctx.Done():
107 | return
108 | default:
109 | _, msg, err := c.connection.ReadMessage()
110 | if err != nil {
111 | c.error <- fmt.Sprintf("read err: %s", err)
112 | return
113 | }
114 | c.device.SetState(msg)
115 | }
116 | }
117 | }
118 |
119 | func (c *Conversation) pingConn(ctx context.Context) {
120 | ticker := time.NewTicker(pingWait)
121 | defer ticker.Stop()
122 |
123 | c.connection.SetPongHandler(func(string) error {
124 | return c.connection.SetReadDeadline(time.Now().Add(pongWait))
125 | })
126 |
127 | for c.connected {
128 | select {
129 | case <-ticker.C:
130 | if err := c.pingDevice(); err != nil {
131 | c.error <- fmt.Sprintf("ping err: %s", err)
132 | return
133 | }
134 | case <-ctx.Done():
135 | return
136 | }
137 | }
138 | }
139 |
140 | func (c *Conversation) refreshToken(ctx context.Context) {
141 | ticker := time.NewTicker(time.Hour * 1)
142 | defer ticker.Stop()
143 |
144 | for c.connected {
145 | select {
146 | case <-ticker.C:
147 | if err := c.device.RefreshToken(ctx); err != nil {
148 | c.error <- fmt.Sprintf("refresh token: %s", err)
149 | }
150 | case <-ctx.Done():
151 | return
152 | }
153 | }
154 | }
155 |
156 | func (c *Conversation) pingDevice() (err error) {
157 | return c.SendToDevice(Payload{Command: "ping"})
158 | }
159 |
160 | type DeviceRequest struct {
161 | ConversationToken string `json:"conversationToken"`
162 | Id string `json:"id"`
163 | SentTime int64 `json:"sentTime"`
164 | Payload Payload `json:"payload"`
165 | }
166 |
167 | type Payload struct {
168 | Command string `json:"command"`
169 | Volume float32 `json:"volume"`
170 | Position int8 `json:"position"`
171 | Text string `json:"text"`
172 | }
173 |
--------------------------------------------------------------------------------
/internal/socket/conversation_test.go:
--------------------------------------------------------------------------------
1 | package socket
2 |
3 | import (
4 | "context"
5 | "github.com/gorilla/websocket"
6 | "github.com/stretchr/testify/require"
7 | "net/http"
8 | "net/http/httptest"
9 | "strings"
10 | "testing"
11 | )
12 |
13 | type device struct {
14 | host string
15 | }
16 |
17 | func (d device) GetState() []byte { return nil }
18 | func (d device) SetState(_ []byte) {}
19 | func (d device) GetHost() string { return d.host }
20 | func (d device) RefreshToken(context.Context) error { return nil }
21 | func (d device) GetToken() string { return "" }
22 | func (d device) GetCertificate() string { return testCert }
23 |
24 | func TestConversation_Connect(t *testing.T) {
25 | s := newServer(t)
26 | defer s.Close()
27 |
28 | conn := NewConversation(device{makeWsProto(s.URL)})
29 |
30 | err := conn.Connect(context.Background())
31 | defer conn.Close()
32 |
33 | require.Nil(t, err)
34 | }
35 |
36 | func TestConversation_Run(t *testing.T) {
37 | s := newServer(t)
38 | defer s.Close()
39 |
40 | conn := NewConversation(device{makeWsProto(s.URL)})
41 |
42 | err := conn.Connect(context.Background())
43 | defer conn.Close()
44 |
45 | require.Nil(t, err)
46 |
47 | go func() {
48 | err = conn.Run(context.Background())
49 | require.NotNil(t, err)
50 | }()
51 |
52 | conn.error <- "err"
53 | }
54 |
55 | func newServer(t *testing.T) *httptest.Server {
56 | return httptest.NewServer(cstHandler{
57 | t: t,
58 | upgrader: websocket.Upgrader{},
59 | })
60 | }
61 |
62 | func makeWsProto(s string) string {
63 | return "ws" + strings.TrimPrefix(s, "http")
64 | }
65 |
66 | type cstHandler struct {
67 | t *testing.T
68 | upgrader websocket.Upgrader
69 | }
70 |
71 | func (h cstHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
72 | ws, err := h.upgrader.Upgrade(w, r, nil)
73 | require.Nil(h.t, err)
74 | defer func() { _ = ws.Close() }()
75 | }
76 |
--------------------------------------------------------------------------------
/internal/socket/socket.go:
--------------------------------------------------------------------------------
1 | package socket
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "log"
8 | "net/http"
9 | "time"
10 | )
11 |
12 | type socket struct {
13 | conn *Conversation
14 | }
15 |
16 | func NewSocket(conn *Conversation) Socket {
17 | return &socket{conn}
18 | }
19 |
20 | func (s *socket) Run(ctx context.Context) (err error) {
21 | log.Println("start socket connection")
22 |
23 | if s.conn == nil {
24 | return errors.New("no socket connection")
25 | }
26 |
27 | if err = s.conn.Connect(ctx); err != nil {
28 | return errors.New("connect error: " + err.Error())
29 | }
30 |
31 | ctx, cancel := context.WithCancel(ctx)
32 | go func() {
33 | if err = s.conn.Run(ctx); err != nil {
34 | cancel()
35 | s.conn.Close()
36 | time.Sleep(time.Second * 1)
37 | log.Fatalln(err)
38 | }
39 | }()
40 | return
41 | }
42 |
43 | func (s *socket) Write(w http.ResponseWriter, r *http.Request) {
44 | msg := Payload{}
45 | if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
46 | return
47 | }
48 |
49 | if err := s.conn.SendToDevice(msg); err != nil {
50 | http.Error(w, err.Error(), http.StatusInternalServerError)
51 | }
52 | }
53 |
54 | func (s *socket) Read(w http.ResponseWriter, _ *http.Request) {
55 | w.Header().Set("Content-Type", "application/json")
56 | _, _ = w.Write(s.conn.ReadFromDevice())
57 | }
58 |
--------------------------------------------------------------------------------
/pkg/mdns/mdns.go:
--------------------------------------------------------------------------------
1 | package mdns
2 |
3 | import (
4 | "errors"
5 | "log"
6 | "strconv"
7 | "strings"
8 |
9 | "github.com/hashicorp/mdns"
10 | )
11 |
12 | const YandexServicePrefix = "_yandexio._tcp"
13 |
14 | type Entry struct {
15 | Discovered bool
16 | IpAddr string
17 | Port string
18 | }
19 |
20 | func Discover(deviceId, service string) (entry Entry, err error) {
21 | entriesCh := make(chan *mdns.ServiceEntry)
22 | defer close(entriesCh)
23 |
24 | go func(entry *Entry) {
25 | for entryCh := range entriesCh {
26 | if deviceId == getDeviceId(entryCh) {
27 | entry.IpAddr = entryCh.AddrV4.String()
28 | entry.Port = strconv.Itoa(entryCh.Port)
29 | entry.Discovered = true
30 | log.Println("found device on: " + entry.IpAddr)
31 | return
32 | }
33 | }
34 | }(&entry)
35 |
36 | if err = mdns.Lookup(service, entriesCh); err != nil {
37 | return
38 | }
39 | if !entry.Discovered {
40 | err = errors.New("mdns: No device found")
41 | }
42 | return
43 | }
44 |
45 | func getDeviceId(entry *mdns.ServiceEntry) string {
46 | for _, field := range entry.InfoFields {
47 | entryData := strings.Split(field, "=")
48 | if len(entryData) == 2 && entryData[0] == "deviceId" {
49 | return entryData[1]
50 | }
51 | }
52 | return ""
53 | }
54 |
--------------------------------------------------------------------------------
/pkg/mdns/mdns_test.go:
--------------------------------------------------------------------------------
1 | package mdns
2 |
3 | import (
4 | "github.com/hashicorp/mdns"
5 | "github.com/stretchr/testify/require"
6 | "net"
7 | "testing"
8 | )
9 |
10 | func TestDiscoverDeviceFound(t *testing.T) {
11 | serv, err := mdns.NewServer(&mdns.Config{Zone: makeServiceWithServiceName(t, "_foobar._tcp")})
12 | if err != nil {
13 | require.Nil(t, err)
14 | }
15 | defer func() { _ = serv.Shutdown() }()
16 |
17 | actual, err := Discover("123", "_foobar._tcp")
18 |
19 | require.Nil(t, err)
20 | require.Equal(t, "80", actual.Port)
21 | require.Equal(t, "192.168.0.42", actual.IpAddr)
22 | }
23 |
24 | func TestDiscoverDeviceNotFound(t *testing.T) {
25 | serv, err := mdns.NewServer(&mdns.Config{Zone: makeServiceWithServiceName(t, "_foobar._tcp")})
26 | if err != nil {
27 | require.Nil(t, err)
28 | }
29 | defer func() { _ = serv.Shutdown() }()
30 |
31 | actual, err := Discover("124", "_foobar._tcp")
32 |
33 | require.NotNil(t, err)
34 | require.False(t, actual.Discovered)
35 | }
36 |
37 | func makeServiceWithServiceName(t *testing.T, service string) *mdns.MDNSService {
38 | m, err := mdns.NewMDNSService(
39 | "hostname",
40 | service,
41 | "local.",
42 | "testhost.",
43 | 80,
44 | []net.IP{[]byte{192, 168, 0, 42}, net.ParseIP("2620:0:1000:1900:b0c2:d0b2:c411:18bc")},
45 | []string{"deviceId=123"},
46 | )
47 |
48 | if err != nil {
49 | require.Nil(t, err)
50 | }
51 |
52 | return m
53 | }
54 |
--------------------------------------------------------------------------------
/yapi:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ebuyan/yapi/063a871acbf3c06a68233e9d0e3a36490b8927bc/yapi
--------------------------------------------------------------------------------
/yapi.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=yapi
3 | ConditionPathExists=/opt/yapi/yapi
4 | After=network.target
5 |
6 | [Service]
7 | Type=simple
8 | User=root
9 | Group=root
10 |
11 | WorkingDirectory=/opt/yapi
12 | ExecStart=/opt/yapi/yapi
13 | StandardError=append:/var/log/yapi/app.log
14 |
15 | Restart=on-failure
16 | RestartSec=10
17 |
18 | ExecStartPre=/bin/chown syslog:adm /var/log/yapi
19 | ExecStartPre=/bin/chmod 775 /opt/yapi/yapi
20 |
21 | [Install]
22 | WantedBy=multi-user.target
23 |
--------------------------------------------------------------------------------
/yapi_arm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ebuyan/yapi/063a871acbf3c06a68233e9d0e3a36490b8927bc/yapi_arm
--------------------------------------------------------------------------------