├── .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 --------------------------------------------------------------------------------