diff --git a/README.md b/README.md index b4a0f56..a60e787 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,89 @@ The library intentionally does not know anything about snapshots, route progress, geolocation, frontend state, or media playback rules. It only parses, validates, stores, logs, and returns BVK content state. +## Install + +```bash +go get gitea.unprism.ru/KRBL/mila +``` + +## Scope + +Mila is a protocol library. It should be embedded into an application service, +for example `wn-content`, and exposed on the address required by the transport +integration. + +Mila does: + +- parse `GET /set-content` +- parse `GET /get-content` +- validate `route`, `str`, and `tpl_name` +- URL-decode query parameters through Go's standard HTTP parser +- store the latest state in memory or in a JSON file +- return protocol-compatible JSON +- log each HTTP request with method, path, status, remote address, and duration +- call an optional callback after successful `set-content` + +Mila does not: + +- calculate route progress +- work with geolocation or NMEA +- map `tpl_name` to real media playlists +- read or write snapshots +- communicate with frontend directly +- decide how content should be rendered + +## Protocol + +The media complex acts as an HTTP server. BVK acts as an HTTP client. + +Default address from the PDF: + +```text +172.16.16.2:13280 +``` + +Endpoints: + +```text +GET /set-content?route=&str=&tpl_name= +GET /get-content +``` + +`set-content` accepts URL-encoded query parameters: + +| Parameter | Required | Meaning | +| --- | --- | --- | +| `route` | yes | route number, default limit is 1-3 characters | +| `str` | no | ticker/running text, default limit is 200 characters | +| `tpl_name` | yes | media content template name, default limit is 200 characters | + +`get-content` response: + +```json +{ + "route": "3", + "str": "Гостиный двор - Gostiny dvor", + "tpl_name": "Гостиный двор" +} +``` + +`updated_at` is stored internally but is not exposed by `/get-content`, because +the protocol response should stay strict. + ## Usage ```go +package main + +import ( + "log" + "net/http" + + "gitea.unprism.ru/KRBL/mila" +) + +func main() { store := mila.NewFileStore("/srv/persistence/client_logs/bvk_content.json") handler := mila.NewHandler(mila.DefaultConfig(), store, @@ -29,9 +109,68 @@ srv := &http.Server{ Addr: "0.0.0.0:13280", Handler: handler, } + +log.Fatal(srv.ListenAndServe()) +} ``` -## State +## Configuration + +Default validation config: + +```go +cfg := mila.DefaultConfig() +``` + +Which currently means: + +```go +mila.Config{ + MaxRouteLen: 3, + MaxTextLen: 200, + MaxTemplateLen: 200, +} +``` + +The text/template limits should be confirmed with the integrator. The provided +PDF has an ambiguous text layer for those values. + +## Stores + +Use `MemoryStore` when state does not need to survive process restart: + +```go +store := mila.NewMemoryStore() +``` + +Use `FileStore` on a device when the latest BVK state should be persisted: + +```go +store := mila.NewFileStore("/srv/persistence/client_logs/bvk_content.json") +``` + +`FileStore` writes atomically through a temporary file and keeps an in-memory +copy after the first successful read or write. + +## Callback + +`WithOnSet` lets the host application react to a valid BVK update: + +```go +handler := mila.NewHandler(mila.DefaultConfig(), store, + mila.WithOnSet(func(state mila.ContentState) { + // Example host-side decisions: + // - publish route and ticker to frontend state + // - map state.Template to a local playlist/template + // - write application-specific logs + }), +) +``` + +The callback is intentionally synchronous. Keep it fast; if integration work is +heavy, push the state into an internal queue. + +## State Model ```json { @@ -41,3 +180,48 @@ srv := &http.Server{ "updated_at": "2026-07-12T12:00:00Z" } ``` + +Go type: + +```go +type ContentState struct { + Route string `json:"route"` + Text string `json:"str"` + Template string `json:"tpl_name"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} +``` + +## Status Codes + +| Code | When | +| ---: | --- | +| 200 | valid `set-content` or ready `get-content` | +| 400 | invalid request parameters | +| 404 | unknown path | +| 405 | method is not `GET` | +| 500 | store/internal error | +| 503 | `get-content` before first state, canceled/deadline context, unavailable dependency | + +Error response: + +```json +{ + "error": "route: must be at most 3 characters" +} +``` + +## Testing + +```bash +go test ./... +``` + +Current tests cover: + +- successful `set-content` + `get-content` +- `get-content` before state is ready +- invalid route validation +- UTF-8 text/template validation +- file store persistence +- missing file store state diff --git a/store_file_test.go b/store_file_test.go new file mode 100644 index 0000000..9446756 --- /dev/null +++ b/store_file_test.go @@ -0,0 +1,47 @@ +package mila + +import ( + "context" + "path/filepath" + "testing" +) + +func TestFileStorePersistsAndLoadsState(t *testing.T) { + path := filepath.Join(t.TempDir(), "bvk_content.json") + ctx := context.Background() + + first := NewFileStore(path) + state := ContentState{ + Route: "3", + Text: "Гостиный двор", + Template: "main", + } + if err := first.Set(ctx, state); err != nil { + t.Fatalf("set state: %v", err) + } + + second := NewFileStore(path) + got, err := second.Get(ctx) + if err != nil { + t.Fatalf("get state: %v", err) + } + + if got.Route != state.Route || got.Text != state.Text || got.Template != state.Template { + t.Fatalf("unexpected state: %+v", got) + } + if got.UpdatedAt.IsZero() != state.UpdatedAt.IsZero() { + t.Fatalf("unexpected updated_at value: %s", got.UpdatedAt) + } +} + +func TestFileStoreMissingFileReturnsNotReady(t *testing.T) { + store := NewFileStore(filepath.Join(t.TempDir(), "missing.json")) + + _, err := store.Get(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if err != ErrNotReady { + t.Fatalf("expected ErrNotReady, got %v", err) + } +}