2019-06-02 20:12:36 +03:00
|
|
|
package bluetooth
|
|
|
|
|
2020-06-04 17:53:17 +03:00
|
|
|
import (
|
|
|
|
"errors"
|
|
|
|
"time"
|
|
|
|
)
|
2020-05-28 00:13:04 +03:00
|
|
|
|
|
|
|
var (
|
2020-06-01 15:07:07 +03:00
|
|
|
errScanning = errors.New("bluetooth: a scan is already in progress")
|
|
|
|
errNotScanning = errors.New("bluetooth: there is no scan in progress")
|
|
|
|
errAdvertisementPacketTooBig = errors.New("bluetooth: advertisement packet overflows")
|
2020-05-28 00:13:04 +03:00
|
|
|
)
|
|
|
|
|
2020-08-29 15:43:11 +03:00
|
|
|
// MACAddress contains a Bluetooth address which is a MAC address.
|
|
|
|
type MACAddress struct {
|
|
|
|
// MAC address of the Bluetooth device.
|
|
|
|
MAC
|
2020-08-30 15:53:17 +03:00
|
|
|
|
2020-08-29 15:43:11 +03:00
|
|
|
isRandom bool
|
|
|
|
}
|
|
|
|
|
|
|
|
// IsRandom if the address is randomly created.
|
|
|
|
func (mac MACAddress) IsRandom() bool {
|
|
|
|
return mac.isRandom
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetRandom if is a random address.
|
2023-04-27 17:54:43 +03:00
|
|
|
func (mac *MACAddress) SetRandom(val bool) {
|
2020-08-29 15:43:11 +03:00
|
|
|
mac.isRandom = val
|
|
|
|
}
|
|
|
|
|
|
|
|
// Set the address
|
2023-04-27 17:54:43 +03:00
|
|
|
func (mac *MACAddress) Set(val string) {
|
2020-09-20 12:28:39 +03:00
|
|
|
m, err := ParseMAC(val)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-08-30 15:53:17 +03:00
|
|
|
mac.MAC = m
|
2020-08-29 15:43:11 +03:00
|
|
|
}
|
|
|
|
|
2020-06-01 15:07:07 +03:00
|
|
|
// AdvertisementOptions configures an advertisement instance. More options may
|
|
|
|
// be added over time.
|
|
|
|
type AdvertisementOptions struct {
|
|
|
|
// The (complete) local name that will be advertised. Optional, omitted if
|
|
|
|
// this is a zero-length string.
|
|
|
|
LocalName string
|
|
|
|
|
2020-06-02 15:00:32 +03:00
|
|
|
// ServiceUUIDs are the services (16-bit or 128-bit) that are broadcast as
|
|
|
|
// part of the advertisement packet, in data types such as "complete list of
|
|
|
|
// 128-bit UUIDs".
|
|
|
|
ServiceUUIDs []UUID
|
|
|
|
|
2020-06-06 21:47:05 +03:00
|
|
|
// Interval in BLE-specific units. Create an interval by using NewDuration.
|
|
|
|
Interval Duration
|
2023-07-25 22:15:10 +03:00
|
|
|
|
|
|
|
// ManufacturerData stores Advertising Data.
|
|
|
|
// Keys are the Manufacturer ID to associate with the data.
|
2024-02-20 23:16:29 +03:00
|
|
|
ManufacturerData []ManufacturerDataElement
|
|
|
|
}
|
|
|
|
|
|
|
|
// Manufacturer data that's part of an advertisement packet.
|
|
|
|
type ManufacturerDataElement struct {
|
|
|
|
// The company ID, which must be one of the assigned company IDs.
|
|
|
|
// The full list is in here:
|
|
|
|
// https://www.bluetooth.com/specifications/assigned-numbers/
|
|
|
|
// The list can also be viewed here:
|
|
|
|
// https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/company_identifiers/company_identifiers.yaml
|
|
|
|
// The value 0xffff can also be used for testing.
|
|
|
|
CompanyID uint16
|
|
|
|
|
|
|
|
// The value, which can be any value but can't be very large.
|
|
|
|
Data []byte
|
2019-06-02 20:12:36 +03:00
|
|
|
}
|
|
|
|
|
2020-06-06 21:47:05 +03:00
|
|
|
// Duration is the unit of time used in BLE, in 0.625µs units. This unit of time
|
|
|
|
// is used throughout the BLE stack.
|
|
|
|
type Duration uint16
|
2019-06-02 20:12:36 +03:00
|
|
|
|
2020-06-06 21:47:05 +03:00
|
|
|
// NewDuration returns a new Duration, in units of 0.625µs. It is used both for
|
|
|
|
// advertisement intervals and for connection parameters.
|
|
|
|
func NewDuration(interval time.Duration) Duration {
|
2020-06-04 17:53:17 +03:00
|
|
|
// Convert an interval to units of 0.625µs.
|
2020-06-06 21:47:05 +03:00
|
|
|
return Duration(uint64(interval / (625 * time.Microsecond)))
|
2019-06-02 20:12:36 +03:00
|
|
|
}
|
2019-11-09 15:07:07 +03:00
|
|
|
|
|
|
|
// Connection is a numeric identifier that indicates a connection handle.
|
|
|
|
type Connection uint16
|
|
|
|
|
2020-05-28 00:13:04 +03:00
|
|
|
// ScanResult contains information from when an advertisement packet was
|
|
|
|
// received. It is passed as a parameter to the callback of the Scan method.
|
|
|
|
type ScanResult struct {
|
2020-06-04 13:41:36 +03:00
|
|
|
// Bluetooth address of the scanned device.
|
2023-04-25 03:12:05 +03:00
|
|
|
Address Address
|
2020-05-28 00:13:04 +03:00
|
|
|
|
|
|
|
// RSSI the last time a packet from this device has been received.
|
|
|
|
RSSI int16
|
|
|
|
|
|
|
|
// The data obtained from the advertisement data, which may contain many
|
|
|
|
// different properties.
|
|
|
|
// Warning: this data may only stay valid until the next event arrives. If
|
|
|
|
// you need any of the fields to stay alive until after the callback
|
|
|
|
// returns, copy them.
|
|
|
|
AdvertisementPayload
|
|
|
|
}
|
|
|
|
|
|
|
|
// AdvertisementPayload contains information obtained during a scan (see
|
|
|
|
// ScanResult). It is provided as an interface as there are two possible
|
|
|
|
// implementations: an implementation that works with raw data (usually on
|
|
|
|
// low-level BLE stacks) and an implementation that works with structured data.
|
|
|
|
type AdvertisementPayload interface {
|
|
|
|
// LocalName is the (complete or shortened) local name of the device.
|
|
|
|
// Please note that many devices do not broadcast a local name, but may
|
|
|
|
// broadcast other data (e.g. manufacturer data or service UUIDs) with which
|
|
|
|
// they may be identified.
|
|
|
|
LocalName() string
|
|
|
|
|
2020-06-04 19:51:01 +03:00
|
|
|
// HasServiceUUID returns true whether the given UUID is present in the
|
|
|
|
// advertisement payload as a Service Class UUID. It checks both 16-bit
|
|
|
|
// UUIDs and 128-bit UUIDs.
|
|
|
|
HasServiceUUID(UUID) bool
|
|
|
|
|
2020-05-28 00:13:04 +03:00
|
|
|
// Bytes returns the raw advertisement packet, if available. It returns nil
|
|
|
|
// if this data is not available.
|
|
|
|
Bytes() []byte
|
2022-05-11 17:11:52 +03:00
|
|
|
|
|
|
|
// ManufacturerData returns a map with all the manufacturer data present in the
|
|
|
|
//advertising. IT may be empty.
|
2024-02-20 23:16:29 +03:00
|
|
|
ManufacturerData() []ManufacturerDataElement
|
2020-05-28 00:13:04 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// AdvertisementFields contains advertisement fields in structured form.
|
|
|
|
type AdvertisementFields struct {
|
|
|
|
// The LocalName part of the advertisement (either the complete local name
|
|
|
|
// or the shortened local name).
|
|
|
|
LocalName string
|
2020-06-04 19:51:01 +03:00
|
|
|
|
|
|
|
// ServiceUUIDs are the services (16-bit or 128-bit) that are broadcast as
|
|
|
|
// part of the advertisement packet, in data types such as "complete list of
|
|
|
|
// 128-bit UUIDs".
|
|
|
|
ServiceUUIDs []UUID
|
2022-05-11 17:11:52 +03:00
|
|
|
|
|
|
|
// ManufacturerData is the manufacturer data of the advertisement.
|
2024-02-20 23:16:29 +03:00
|
|
|
ManufacturerData []ManufacturerDataElement
|
2020-05-28 00:13:04 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// advertisementFields wraps AdvertisementFields to implement the
|
|
|
|
// AdvertisementPayload interface. The methods to implement the interface (such
|
|
|
|
// as LocalName) cannot be implemented on AdvertisementFields because they would
|
|
|
|
// conflict with field names.
|
|
|
|
type advertisementFields struct {
|
|
|
|
AdvertisementFields
|
|
|
|
}
|
|
|
|
|
|
|
|
// LocalName returns the underlying LocalName field.
|
|
|
|
func (p *advertisementFields) LocalName() string {
|
|
|
|
return p.AdvertisementFields.LocalName
|
|
|
|
}
|
|
|
|
|
2020-06-04 19:51:01 +03:00
|
|
|
// HasServiceUUID returns true whether the given UUID is present in the
|
|
|
|
// advertisement payload as a Service Class UUID.
|
|
|
|
func (p *advertisementFields) HasServiceUUID(uuid UUID) bool {
|
|
|
|
for _, u := range p.AdvertisementFields.ServiceUUIDs {
|
|
|
|
if u == uuid {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2020-05-28 00:13:04 +03:00
|
|
|
// Bytes returns nil, as structured advertisement data does not have the
|
|
|
|
// original raw advertisement data available.
|
|
|
|
func (p *advertisementFields) Bytes() []byte {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-05-11 17:11:52 +03:00
|
|
|
// ManufacturerData returns the underlying ManufacturerData field.
|
2024-02-20 23:16:29 +03:00
|
|
|
func (p *advertisementFields) ManufacturerData() []ManufacturerDataElement {
|
2022-05-11 17:11:52 +03:00
|
|
|
return p.AdvertisementFields.ManufacturerData
|
|
|
|
}
|
|
|
|
|
2020-05-28 00:13:04 +03:00
|
|
|
// rawAdvertisementPayload encapsulates a raw advertisement packet. Methods to
|
|
|
|
// get the data (such as LocalName()) will parse just the needed field. Scanning
|
|
|
|
// the data should be fast as most advertisement packets only have a very small
|
|
|
|
// (3 or so) amount of fields.
|
|
|
|
type rawAdvertisementPayload struct {
|
|
|
|
data [31]byte
|
|
|
|
len uint8
|
|
|
|
}
|
|
|
|
|
|
|
|
// Bytes returns the raw advertisement packet as a byte slice.
|
|
|
|
func (buf *rawAdvertisementPayload) Bytes() []byte {
|
|
|
|
return buf.data[:buf.len]
|
|
|
|
}
|
|
|
|
|
|
|
|
// findField returns the data of a specific field in the advertisement packet.
|
2020-06-04 19:51:01 +03:00
|
|
|
//
|
|
|
|
// See this list of field types:
|
|
|
|
// https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/
|
2020-05-28 00:13:04 +03:00
|
|
|
func (buf *rawAdvertisementPayload) findField(fieldType byte) []byte {
|
|
|
|
data := buf.Bytes()
|
|
|
|
for len(data) >= 2 {
|
|
|
|
fieldLength := data[0]
|
|
|
|
if int(fieldLength)+1 > len(data) {
|
|
|
|
// Invalid field length.
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
if fieldType == data[1] {
|
|
|
|
return data[2 : fieldLength+1]
|
|
|
|
}
|
|
|
|
data = data[fieldLength+1:]
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// LocalName returns the local name (complete or shortened) in the advertisement
|
|
|
|
// payload.
|
|
|
|
func (buf *rawAdvertisementPayload) LocalName() string {
|
|
|
|
b := buf.findField(9) // Complete Local Name
|
|
|
|
if len(b) != 0 {
|
|
|
|
return string(b)
|
|
|
|
}
|
|
|
|
b = buf.findField(8) // Shortened Local Name
|
|
|
|
if len(b) != 0 {
|
|
|
|
return string(b)
|
|
|
|
}
|
|
|
|
return ""
|
|
|
|
}
|
2020-06-01 15:07:07 +03:00
|
|
|
|
2020-06-04 19:51:01 +03:00
|
|
|
// HasServiceUUID returns true whether the given UUID is present in the
|
|
|
|
// advertisement payload as a Service Class UUID. It checks both 16-bit UUIDs
|
|
|
|
// and 128-bit UUIDs.
|
|
|
|
func (buf *rawAdvertisementPayload) HasServiceUUID(uuid UUID) bool {
|
|
|
|
if uuid.Is16Bit() {
|
|
|
|
b := buf.findField(0x03) // Complete List of 16-bit Service Class UUIDs
|
|
|
|
if len(b) == 0 {
|
|
|
|
b = buf.findField(0x02) // Incomplete List of 16-bit Service Class UUIDs
|
|
|
|
}
|
|
|
|
uuid := uuid.Get16Bit()
|
|
|
|
for i := 0; i < len(b)/2; i++ {
|
|
|
|
foundUUID := uint16(b[i*2]) | (uint16(b[i*2+1]) << 8)
|
|
|
|
if uuid == foundUUID {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
} else {
|
|
|
|
b := buf.findField(0x07) // Complete List of 128-bit Service Class UUIDs
|
|
|
|
if len(b) == 0 {
|
|
|
|
b = buf.findField(0x06) // Incomplete List of 128-bit Service Class UUIDs
|
|
|
|
}
|
|
|
|
uuidBuf1 := uuid.Bytes()
|
|
|
|
for i := 0; i < len(b)/16; i++ {
|
|
|
|
uuidBuf2 := b[i*16 : i*16+16]
|
|
|
|
match := true
|
|
|
|
for i, c := range uuidBuf1 {
|
|
|
|
if c != uuidBuf2[i] {
|
|
|
|
match = false
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if match {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-11 17:11:52 +03:00
|
|
|
// ManufacturerData returns the manufacturer data in the advertisement payload.
|
2024-02-20 23:16:29 +03:00
|
|
|
func (buf *rawAdvertisementPayload) ManufacturerData() []ManufacturerDataElement {
|
|
|
|
var manufacturerData []ManufacturerDataElement
|
|
|
|
for index := 0; index < int(buf.len)+4; index += int(buf.data[index]) + 1 {
|
|
|
|
fieldLength := int(buf.data[index+0])
|
|
|
|
if fieldLength < 3 {
|
|
|
|
continue
|
2022-05-11 17:11:52 +03:00
|
|
|
}
|
2024-02-20 23:16:29 +03:00
|
|
|
fieldType := buf.data[index+1]
|
|
|
|
if fieldType != 0xff {
|
|
|
|
continue
|
2022-05-11 17:11:52 +03:00
|
|
|
}
|
2024-02-20 23:16:29 +03:00
|
|
|
key := uint16(buf.data[index+2]) | uint16(buf.data[index+3])<<8
|
|
|
|
manufacturerData = append(manufacturerData, ManufacturerDataElement{
|
|
|
|
CompanyID: key,
|
|
|
|
Data: buf.data[index+4 : index+fieldLength+1],
|
|
|
|
})
|
2022-05-11 17:11:52 +03:00
|
|
|
}
|
2024-02-20 23:16:29 +03:00
|
|
|
return manufacturerData
|
2022-05-11 17:11:52 +03:00
|
|
|
}
|
|
|
|
|
2020-09-16 16:50:26 +03:00
|
|
|
// reset restores this buffer to the original state.
|
|
|
|
func (buf *rawAdvertisementPayload) reset() {
|
|
|
|
// The data is not reset (only the length), because with a zero length the
|
|
|
|
// data is undefined.
|
|
|
|
buf.len = 0
|
|
|
|
}
|
|
|
|
|
2020-06-02 15:00:32 +03:00
|
|
|
// addFromOptions constructs a new advertisement payload (assumed to be empty
|
|
|
|
// before the call) from the advertisement options. It returns true if it fits,
|
|
|
|
// false otherwise.
|
|
|
|
func (buf *rawAdvertisementPayload) addFromOptions(options AdvertisementOptions) (ok bool) {
|
|
|
|
buf.addFlags(0x06)
|
|
|
|
if options.LocalName != "" {
|
|
|
|
if !buf.addCompleteLocalName(options.LocalName) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// TODO: if there are multiple 16-bit UUIDs, they should be listed in
|
|
|
|
// one field.
|
|
|
|
// This is not possible for 128-bit service UUIDs (at least not in
|
|
|
|
// legacy advertising) because of the 31-byte advertisement packet
|
|
|
|
// limit.
|
|
|
|
for _, uuid := range options.ServiceUUIDs {
|
|
|
|
if !buf.addServiceUUID(uuid) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
2023-08-02 18:37:40 +03:00
|
|
|
|
2024-02-20 23:16:29 +03:00
|
|
|
for _, element := range options.ManufacturerData {
|
|
|
|
if !buf.addManufacturerData(element.CompanyID, element.Data) {
|
|
|
|
return false
|
|
|
|
}
|
2023-08-02 18:37:40 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// addManufacturerData adds manufacturer data ([]byte) entries to the advertisement payload.
|
2024-02-20 23:16:29 +03:00
|
|
|
func (buf *rawAdvertisementPayload) addManufacturerData(key uint16, value []byte) (ok bool) {
|
|
|
|
// Check whether the field can fit this manufacturer data.
|
|
|
|
fieldLength := len(value) + 4
|
|
|
|
if int(buf.len)+fieldLength > len(buf.data) {
|
|
|
|
return false
|
|
|
|
}
|
2023-08-02 18:37:40 +03:00
|
|
|
|
2024-02-20 23:16:29 +03:00
|
|
|
// Add the data.
|
|
|
|
buf.data[buf.len+0] = uint8(fieldLength - 1)
|
|
|
|
buf.data[buf.len+1] = 0xff
|
|
|
|
buf.data[buf.len+2] = uint8(key)
|
|
|
|
buf.data[buf.len+3] = uint8(key >> 8)
|
|
|
|
copy(buf.data[buf.len+4:], value)
|
|
|
|
buf.len += uint8(fieldLength)
|
2023-08-02 18:37:40 +03:00
|
|
|
|
2020-06-02 15:00:32 +03:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2020-06-01 15:07:07 +03:00
|
|
|
// addFlags adds a flags field to the advertisement buffer. It returns true on
|
|
|
|
// success (the flags can be added) and false on failure.
|
|
|
|
func (buf *rawAdvertisementPayload) addFlags(flags byte) (ok bool) {
|
|
|
|
if int(buf.len)+3 > len(buf.data) {
|
|
|
|
return false // flags don't fit
|
|
|
|
}
|
|
|
|
|
|
|
|
buf.data[buf.len] = 2 // length of field (including type)
|
|
|
|
buf.data[buf.len+1] = 0x01 // type, 0x01 means Flags
|
|
|
|
buf.data[buf.len+2] = flags // the flags
|
|
|
|
buf.len += 3
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2020-06-02 15:00:32 +03:00
|
|
|
// addCompleteLocalName adds the Complete Local Name field to the advertisement
|
|
|
|
// buffer. It returns true on success (the name fits) and false on failure.
|
2020-06-01 15:07:07 +03:00
|
|
|
func (buf *rawAdvertisementPayload) addCompleteLocalName(name string) (ok bool) {
|
|
|
|
if int(buf.len)+len(name)+2 > len(buf.data) {
|
|
|
|
return false // name doesn't fit
|
|
|
|
}
|
|
|
|
|
|
|
|
buf.data[buf.len] = byte(len(name) + 1) // length of field (including type)
|
|
|
|
buf.data[buf.len+1] = 9 // type, 0x09 means Complete Local name
|
|
|
|
copy(buf.data[buf.len+2:], name) // copy the name into the buffer
|
|
|
|
buf.len += byte(len(name) + 2)
|
|
|
|
return true
|
|
|
|
}
|
2020-06-02 15:00:32 +03:00
|
|
|
|
|
|
|
// addServiceUUID adds a Service Class UUID (16-bit or 128-bit). It has
|
|
|
|
// currently only been designed for adding single UUIDs: multiple UUIDs are
|
|
|
|
// stored in separate fields without joining them together in one field.
|
|
|
|
func (buf *rawAdvertisementPayload) addServiceUUID(uuid UUID) (ok bool) {
|
|
|
|
// Don't bother with 32-bit UUID support, it doesn't seem to be used in
|
|
|
|
// practice.
|
|
|
|
if uuid.Is16Bit() {
|
|
|
|
if int(buf.len)+4 > len(buf.data) {
|
|
|
|
return false // UUID doesn't fit.
|
|
|
|
}
|
|
|
|
shortUUID := uuid.Get16Bit()
|
|
|
|
buf.data[buf.len+0] = 3 // length of field, including type
|
|
|
|
buf.data[buf.len+1] = 0x03 // type, 0x03 means "Complete List of 16-bit Service Class UUIDs"
|
|
|
|
buf.data[buf.len+2] = byte(shortUUID)
|
|
|
|
buf.data[buf.len+3] = byte(shortUUID >> 8)
|
|
|
|
buf.len += 4
|
|
|
|
return true
|
|
|
|
} else {
|
|
|
|
if int(buf.len)+18 > len(buf.data) {
|
|
|
|
return false // UUID doesn't fit.
|
|
|
|
}
|
|
|
|
buf.data[buf.len+0] = 17 // length of field, including type
|
|
|
|
buf.data[buf.len+1] = 0x07 // type, 0x07 means "Complete List of 128-bit Service Class UUIDs"
|
|
|
|
rawUUID := uuid.Bytes()
|
|
|
|
copy(buf.data[buf.len+2:], rawUUID[:])
|
|
|
|
buf.len += 18
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
2020-06-11 17:52:04 +03:00
|
|
|
|
2023-12-25 17:29:49 +03:00
|
|
|
// ConnectionParams are used when connecting to a peripherals or when changing
|
|
|
|
// the parameters of an active connection.
|
2020-06-11 17:52:04 +03:00
|
|
|
type ConnectionParams struct {
|
|
|
|
// The timeout for the connection attempt. Not used during the rest of the
|
|
|
|
// connection. If no duration is specified, a default timeout will be used.
|
|
|
|
ConnectionTimeout Duration
|
|
|
|
|
|
|
|
// Minimum and maximum connection interval. The shorter the interval, the
|
|
|
|
// faster data can travel between both devices but also the more power they
|
|
|
|
// will draw. If no intervals are specified, a default connection interval
|
|
|
|
// will be used.
|
|
|
|
MinInterval Duration
|
|
|
|
MaxInterval Duration
|
2023-12-25 17:29:49 +03:00
|
|
|
|
|
|
|
// Connection Supervision Timeout. After this time has passed with no
|
|
|
|
// communication, the connection is considered lost. If no timeout is
|
|
|
|
// specified, the timeout will be unchanged.
|
|
|
|
Timeout Duration
|
2020-06-11 17:52:04 +03:00
|
|
|
}
|