Commit 9c98b5e3 authored by Marc Gravell's avatar Marc Gravell

NRediSearch

parent b9ee9454
{
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceRoot}\\BasicTest\\bin\\Debug\\netcoreapp1.0\\BasicTest.dll",
"args": [],
"cwd": "${workspaceRoot}",
"externalConsole": false,
"stopAtEntry": false,
"internalConsoleOptions": "openOnSessionStart"
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach",
"processId": "${command.pickProcess}"
}
]
}
\ No newline at end of file
{
"version": "0.1.0",
"command": "dotnet",
"isShellCommand": true,
"args": [],
"tasks": [
{
"taskName": "build",
"args": [
"${workspaceRoot}\\BasicTest\\BasicTest.csproj"
],
"isBuildCommand": true,
"problemMatcher": "$msCompile"
}
]
}
\ No newline at end of file
......@@ -5,13 +5,35 @@
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
using System.IO;
[assembly: AssemblyVersion("1.0.0")]
namespace BasicTest
{
class Program
static class YourPreferredSerializer
{
public static T Deserialize<T>(Stream s) { return default(T); }
}
static class Program
{
public static RedisValue JsonGet(this IDatabase db, RedisKey key,
string path = ".", CommandFlags flags = CommandFlags.None)
{
return (RedisValue)db.Execute("JSON.GET",
new object[] { key, path }, flags);
}
public static T JsonGet<T>(this IDatabase db, RedisKey key,
string path = ".", CommandFlags flags = CommandFlags.None)
{
byte[] bytes = (byte[])db.Execute("JSON.GET",
new object[] { key, path }, flags);
using (var ms = new MemoryStream(bytes))
{
return YourPreferredSerializer.Deserialize<T>(ms);
}
}
static void Main(string[] args)
{
using (var conn = ConnectionMultiplexer.Connect("127.0.0.1:6379"))
......
using System;
using Xunit;
using StackExchange.Redis;
using NRediSearch;
using System.Collections.Generic;
using System.Linq;
namespace NRediSearch.Test
{
public class ExampleUsage : IDisposable
{
ConnectionMultiplexer conn;
IDatabase db;
public ExampleUsage()
{
conn = ConnectionMultiplexer.Connect("127.0.0.1:6379");
db = conn.GetDatabase();
}
public void Dispose()
{
conn?.Dispose();
conn = null;
db = null;
}
[Fact]
public void BasicUsage()
{
var client = new Client("testung", db);
try { client.DropIndex(); } catch { } // reset DB
// Defining a schema for an index and creating it:
var sc = new Schema()
.AddTextField("title", 5.0)
.AddTextField("body", 1.0)
.AddNumericField("price");
Assert.True(client.CreateIndex(sc, Client.IndexOptions.Default));
// note: using java API equivalent here; it would be nice to
// use meta-programming / reflection instead in .NET
// Adding documents to the index:
var fields = new Dictionary<string, RedisValue>();
fields.Add("title", "hello world");
fields.Add("body", "lorem ipsum");
fields.Add("price", 1337);
Assert.True(client.AddDocument("doc1", fields));
// Creating a complex query
var q = new Query("hello world")
.AddFilter(new Query.NumericFilter("price", 1300, 1350))
.Limit(0, 5);
// actual search
var res = client.Search(q);
Assert.Equal(1, res.TotalResults);
var item = res.Documents.Single();
Assert.Equal("doc1", item.Id);
}
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp1.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" />
<PackageReference Include="xunit" Version="2.2.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.2.0" />
<ProjectReference Include="..\NRediSearch\NRediSearch.csproj" />
<ProjectReference Include="..\StackExchange.Redis\StackExchange.Redis.csproj" />
</ItemGroup>
<ItemGroup>
<Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" />
</ItemGroup>
</Project>
// .NET port of https://github.com/RedisLabs/JRediSearch/
using StackExchange.Redis;
using System;
using System.Collections.Generic;
namespace NRediSearch
{
public sealed class Client
{
[Flags]
public enum IndexOptions
{
/// <summary>
/// Set this to tell the index not to save term offset vectors. This reduces memory consumption but does not
/// allow performing exact matches, and reduces overall relevance of multi-term queries
/// </summary>
UseTermOffsets = 1,
/// <summary>
/// If set (default), we keep flags per index record telling us what fields the term appeared on,
/// and allowing us to filter results by field
/// </summary>
KeepFieldFlags = 2,
/// <summary>
/// If set, we keep an index of the top entries per term, allowing extremely fast single word queries
/// regardless of index size, at the cost of more memory
/// </summary>
UseScoreIndexes = 4,
/// <summary>
/// The default indexing options - use term offsets and keep fields flags
/// </summary>
Default = UseTermOffsets | KeepFieldFlags
}
private static void SerializeRedisArgs(IndexOptions flags, List<object> args)
{
if ((flags & IndexOptions.UseTermOffsets) == 0)
{
args.Add("NOOFFSETS");
}
if ((flags & IndexOptions.KeepFieldFlags) == 0)
{
args.Add("NOFIELDS");
}
if ((flags & IndexOptions.UseScoreIndexes) == 0)
{
args.Add("NOSCOREIDX");
}
}
IDatabase _db;
public RedisKey IndexName { get; }
public Client(RedisKey indexName, IDatabase db)
{
_db = db;
IndexName = indexName;
}
/// <summary>
/// Create the index definition in redis
/// </summary>
/// <param name="schema">a schema definition <seealso cref="Schema"/></param>
/// <param name="options">index option flags <seealso cref="IndexOptions"/></param>
/// <returns>true if successful</returns>
public bool CreateIndex(Schema schema, IndexOptions options)
{
var args = new List<object>();
args.Add(IndexName);
SerializeRedisArgs(options, args);
args.Add("SCHEMA");
foreach (var f in schema.Fields)
{
f.SerializeRedisArgs(args);
}
return (string)_db.Execute("FT.CREATE", args.ToArray()) == "OK";
}
/// <summary>
/// Search the index
/// </summary>
/// <param name="q">a <see cref="Query"/> object with the query string and optional parameters</param>
/// <returns>a <see cref="SearchResult"/> object with the results</returns>
public SearchResult Search(Query q)
{
var args = new List<object>();
args.Add(IndexName);
q.SerializeRedisArgs(args);
var resp = (RedisResult[])_db.Execute("FT.SEARCH", args.ToArray());
return new SearchResult(resp, !q.NoContent, q.WithScores, q.WithPayloads);
}
/// <summary>
/// Add a single document to the query
/// </summary>
/// <param name="docId">the id of the document. It cannot belong to a document already in the index unless replace is set</param>
/// <param name="score">the document's score, floating point number between 0 and 1</param>
/// <param name="fields">a map of the document's fields</param>
/// <param name="noSave">if set, we only index the document and do not save its contents. This allows fetching just doc ids</param>
/// <param name="replace">if set, and the document already exists, we reindex and update it</param>
/// <param name="payload">if set, we can save a payload in the index to be retrieved or evaluated by scoring functions on the server</param>
public bool AddDocument(string docId, double score, Dictionary<string, RedisValue> fields, bool noSave, bool replace, byte[] payload)
{
var args = new List<object> { IndexName, docId, score };
if (noSave)
{
args.Add("NOSAVE");
}
if (replace)
{
args.Add("REPLACE");
}
if (payload != null)
{
args.Add("PAYLOAD");
// TODO: Fix this
args.Add(payload);
}
args.Add("FIELDS");
foreach (var ent in fields)
{
args.Add(ent.Key);
args.Add(ent.Value);
}
return (string)_db.Execute("FT.ADD", args.ToArray()) == "OK";
}
/// <summary>
/// replaceDocument is a convenience for calling addDocument with replace=true
/// </summary>
public bool ReplaceDocument(string docId, double score, Dictionary<string, RedisValue> fields)
=> AddDocument(docId, score, fields, false, true, null);
/** See above */
public bool AddDocument(string docId, double score, Dictionary<string, RedisValue> fields)
=> AddDocument(docId, score, fields, false, false, null);
/** See above */
public bool AddDocument(string docId, Dictionary<string, RedisValue> fields)
=> AddDocument(docId, 1, fields, false, false, null);
/// <summary>
/// Index a document already in redis as a HASH key.
/// </summary>
/// <param name="docId">the id of the document in redis. This must match an existing, unindexed HASH key</param>
/// <param name="score">the document's index score, between 0 and 1</param>
/// <param name="replace">if set, and the document already exists, we reindex and update it</param>
/// <returns>true on success</returns>
public bool AddHash(string docId, double score, bool replace)
{
var args = new List<object> { IndexName, docId, score };
if (replace)
{
args.Add("REPLACE");
}
return (string)_db.Execute("FT.ADDHASH", args.ToArray()) == "OK";
}
/// <summary>
/// Get the index info, including memory consumption and other statistics.
/// </summary>
/// <remarks>TODO: Make a class for easier access to the index properties</remarks>
/// <returns>a map of key/value pairs</returns>
public Dictionary<string, RedisValue> GetInfo()
{
var res = (RedisValue[])_db.Execute("FT.INFO", IndexName);
var info = new Dictionary<string, RedisValue>();
for (int i = 0; i < res.Length; i += 2)
{
var key = (string)res[i];
var val = res[i + 1];
info.Add(key, val);
}
return info;
}
/// <summary>
/// Delete a document from the index.
/// </summary>
/// <param name="docId">the document's id</param>
/// <returns>true if it has been deleted, false if it did not exist</returns>
public bool DeleteDocument(string docId)
{
long r = (long)_db.Execute("FT.DEL", IndexName, docId);
return r == 1;
}
/// <summary>
/// Drop the index and all associated keys, including documents
/// </summary>
/// <returns>true on success</returns>
public bool DropIndex()
{
return (string)_db.Execute("FT.DROP", IndexName) == "OK";
}
/// <summary>
/// Optimize memory consumption of the index by removing extra saved capacity. This does not affect speed
/// </summary>
public long OptimizeIndex()
{
long ret = (long)_db.Execute("FT.OPTIMIZE", IndexName);
return ret;
}
}
}
// .NET port of https://github.com/RedisLabs/JRediSearch/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace NRediSearch
{
/// <summary>
/// Document represents a single indexed document or entity in the engine
/// </summary>
public class Document
{
public string Id { get; }
public double Score { get; }
public byte[] Payload { get; }
private Dictionary<String, RedisValue> properties = new Dictionary<string, RedisValue>();
public Document(string id, double score, byte[] payload)
{
Id = id;
Score = score;
Payload = payload;
}
public static Document Load(string id, double score, byte[] payload, RedisValue[] fields)
{
Document ret = new Document(id, score, payload);
if (fields != null)
{
for (int i = 0; i < fields.Length; i += 2)
{
ret[(string)fields[i]] = fields[i + 1];
}
}
return ret;
}
public RedisValue this[string key]
{
get { return properties.TryGetValue(key, out var val) ? val : default(RedisValue); }
internal set { properties[key] = value; }
}
public bool HasProperty(string key) => properties.ContainsKey(key);
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net45;net46;netstandard1.5</TargetFrameworks><!--net40;-->
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StackExchange.Redis\StackExchange.Redis.csproj" />
</ItemGroup>
</Project>
\ No newline at end of file
// .NET port of https://github.com/RedisLabs/JRediSearch/
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.Globalization;
namespace NRediSearch
{
/// <summary>
/// Query represents query parameters and filters to load results from the engine
/// </summary>
public class Query
{
/// <summary>
/// Filter represents a filtering rules in a query
/// </summary>
public abstract class Filter
{
public string Property { get; }
internal abstract void SerializeRedisArgs(List<object> args);
internal Filter(string property)
{
Property = property;
}
}
/// <summary>
/// NumericFilter wraps a range filter on a numeric field. It can be inclusive or exclusive
/// </summary>
public class NumericFilter : Filter
{
private readonly double min, max;
private readonly bool exclusiveMin, exclusiveMax;
public NumericFilter(string property, double min, bool exclusiveMin, double max, bool exclusiveMax) : base(property)
{
this.min = min;
this.max = max;
this.exclusiveMax = exclusiveMax;
this.exclusiveMin = exclusiveMin;
}
public NumericFilter(string property, double min, double max) : this(property, min, false, max, false) { }
internal override void SerializeRedisArgs(List<object> args)
{
RedisValue FormatNum(double num, bool exclude)
{
if (!exclude || double.IsInfinity(num))
{
return (RedisValue)num; // can use directly
}
// need to add leading bracket
return "(" + num.ToString("G17", NumberFormatInfo.InvariantInfo);
}
args.Add("FILTER");
args.Add(Property);
args.Add(FormatNum(min, exclusiveMin));
args.Add(FormatNum(max, exclusiveMax));
}
}
/// <summary>
/// GeoFilter encapsulates a radius filter on a geographical indexed fields
/// </summary>
public class GeoFilter : Filter
{
private readonly double lon, lat, radius;
private GeoUnit unit;
public GeoFilter(string property, double lon, double lat, double radius, GeoUnit unit) : base(property)
{
this.lon = lon;
this.lat = lat;
this.radius = radius;
this.unit = unit;
}
internal override void SerializeRedisArgs(List<object> args)
{
args.Add("GEOFILTER");
args.Add(Property);
args.Add(lon);
args.Add(lat);
args.Add(radius);
switch (unit)
{
case GeoUnit.Feet: args.Add("ft"); break;
case GeoUnit.Kilometers: args.Add("km"); break;
case GeoUnit.Meters: args.Add("m"); break;
case GeoUnit.Miles: args.Add("mi"); break;
default: throw new InvalidOperationException($"Unknown unit: {unit}");
}
}
}
private struct Paging
{
public int Offset { get; }
public int Count { get; }
public Paging(int offset, int count)
{
Offset = offset;
Count = count;
}
}
/// <summary>
/// The query's filter list. We only support AND operation on all those filters
/// </summary>
List<Filter> _filters = new List<Filter>();
/// <summary>
/// The textual part of the query
/// </summary>
public string QueryString { get; }
/// <summary>
/// The sorting parameters
/// </summary>
Paging _paging = new Paging(0, 10);
/// <summary>
/// Set the query to verbatim mode, disabling stemming and query expansion
/// </summary>
public bool Verbatim { get; set; }
/// <summary>
/// Set the query not to return the contents of documents, and rather just return the ids
/// </summary>
public bool NoContent { get; set; }
/// <summary>
/// Set the query not to filter for stopwords. In general this should not be used
/// </summary>
public bool NoStopwords { get; set; }
/// <summary>
/// Set the query to return a factored score for each results. This is useful to merge results from multiple queries.
/// </summary>
public bool WithScores { get; set; }
/// <summary>
/// Set the query to return object payloads, if any were given
/// </summary>
public bool WithPayloads { get; set; }
/// <summary>
/// Set the query language, for stemming purposes; see http://redisearch.io for documentation on languages and stemming
/// </summary>
public string Language { get; set; }
protected String[] _fields = null;
/// <summary>
/// Set the query payload to be evaluated by the scoring function
/// </summary>
public byte[] Payload { get; set; }
/// <summary>
/// Create a new index
/// </summary>
public Query(String queryString)
{
QueryString = queryString;
}
internal void SerializeRedisArgs(List<object> args)
{
args.Add(QueryString);
if (Verbatim)
{
args.Add("VERBATIM");
}
if (NoContent)
{
args.Add("NOCONTENT");
}
if (NoStopwords)
{
args.Add("NOSTOPWORDS");
}
if (WithScores)
{
args.Add("WITHSCORES");
}
if (WithPayloads)
{
args.Add("WITHPAYLOADS");
}
if (Language != null)
{
args.Add("LANGUAGE");
args.Add(Language);
}
if (_fields != null && _fields.Length > 0)
{
args.Add("INFIELDS");
args.Add(_fields.Length);
args.AddRange(_fields);
}
if (Payload != null)
{
args.Add("PAYLOAD");
args.Add(Payload);
}
if (_paging.Offset != 0 || _paging.Count != 10)
{
args.Add("LIMIT");
args.Add(_paging.Offset);
args.Add(_paging.Count);
}
if (_filters != null && _filters.Count > 0)
{
foreach (var f in _filters)
{
f.SerializeRedisArgs(args);
}
}
}
/// <summary>
/// Limit the results to a certain offset and limit
/// </summary>
/// <param name="offset">the first result to show, zero based indexing</param>
/// <param name="limit">how many results we want to show</param>
/// <returns>the query itself, for builder-style syntax</returns>
public Query Limit(int offset, int count)
{
_paging = new Paging(offset, count);
return this;
}
/// <summary>
/// Add a filter to the query's filter list
/// </summary>
/// <param name="f">either a numeric or geo filter object</param>
/// <returns>the query itself</returns>
public Query AddFilter(Filter f)
{
_filters.Add(f);
return this;
}
/// <summary>
/// Limit the query to results that are limited to a specific set of fields
/// </summary>
/// <param name="fields">a list of TEXT fields in the schemas</param>
/// <returns>the query object itself</returns>
public Query LimitFields(params string[] fields)
{
this._fields = fields;
return this;
}
}
}
// .NET port of https://github.com/RedisLabs/JRediSearch/
using System;
using System.Collections.Generic;
namespace NRediSearch
{
/// <summary>
/// Schema abstracts the schema definition when creating an index.
/// Documents can contain fields not mentioned in the schema, but the index will only index pre-defined fields
/// </summary>
public sealed class Schema
{
public enum FieldType
{
FullText,
Geo,
Numeric
}
public class Field
{
public String Name { get; }
public FieldType Type { get; }
internal Field(string name, FieldType type)
{
Name = name;
Type = type;
}
internal virtual void SerializeRedisArgs(List<object> args)
{
string GetForRedis(FieldType type)
{
switch (type)
{
case FieldType.FullText: return "TEXT";
case FieldType.Geo: return "GEO";
case FieldType.Numeric: return "NUMERIC";
default: throw new ArgumentOutOfRangeException(nameof(type));
}
}
args.Add(Name);
args.Add(GetForRedis(Type));
}
}
public class TextField : Field
{
public double Weight { get; }
internal TextField(string name, double weight = 1.0) : base(name, FieldType.FullText)
{
Weight = weight;
}
internal override void SerializeRedisArgs(List<object> args)
{
base.SerializeRedisArgs(args);
if (Weight != 1.0)
{
args.Add("WEIGHT");
args.Add(Weight);
}
}
}
public List<Field> Fields { get; } = new List<Field>();
/// <summary>
/// Add a text field to the schema with a given weight
/// </summary>
/// <param name="name">the field's name</param>
/// <param name="weight">its weight, a positive floating point number</param>
/// <returns>the schema object</returns>
public Schema AddTextField(string name, double weight = 1.0)
{
Fields.Add(new TextField(name, weight));
return this;
}
/// <summary>
/// Add a numeric field to the schema
/// </summary>
/// <param name="name">the field's name</param
/// <returns>the schema object</returns>
public Schema AddGeoField(string name)
{
Fields.Add(new Field(name, FieldType.Geo));
return this;
}
/// <summary>
/// Add a numeric field to the schema
/// </summary>
/// <param name="name">the field's name</param
/// <returns>the schema object</returns>
public Schema AddNumericField(string name)
{
Fields.Add(new Field(name, FieldType.Numeric));
return this;
}
}
}
// .NET port of https://github.com/RedisLabs/JRediSearch/
using StackExchange.Redis;
using System.Collections.Generic;
namespace NRediSearch
{
/// <summary>
/// SearchResult encapsulates the returned result from a search query.
/// It contains publically accessible fields for the total number of results, and an array of <see cref="Document"/>
/// objects conatining the actual returned documents.
/// </summary>
public class SearchResult
{
public long TotalResults { get; }
public List<Document> Documents { get; }
internal SearchResult(RedisResult[] resp, bool hasContent, bool hasScores, bool hasPayloads)
{
// Calculate the step distance to walk over the results.
// The order of results is id, score (if withScore), payLoad (if hasPayloads), fields
int step = 1;
int scoreOffset = 0;
int contentOffset = 1;
int payloadOffset = 0;
if (hasScores)
{
step += 1;
scoreOffset = 1;
contentOffset += 1;
}
if (hasContent)
{
step += 1;
if (hasPayloads)
{
payloadOffset = scoreOffset + 1;
step += 1;
contentOffset += 1;
}
}
// the first element is always the number of results
TotalResults = (long)resp[0];
var docs = new List<Document>((resp.Length - 1) / step);
Documents = docs;
for (int i = 1; i < resp.Length; i += step)
{
var id = (string)resp[i];
double score = 1.0;
byte[] payload = null;
RedisValue[] fields = null;
if (hasScores)
{
score = (double)resp[i + scoreOffset];
}
if (hasPayloads)
{
payload = (byte[])resp[i + payloadOffset];
}
if (hasContent)
{
fields = (RedisValue[])resp[i + contentOffset];
}
docs.Add(Document.Load(id, score, payload, fields));
}
}
}
}
......@@ -40,6 +40,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MigratedBookSleeveTestSuite
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Docs", "Docs\Docs.csproj", "{7909952C-0F38-4E62-A7BA-1A77E1452FDA}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NRediSearch", "NRediSearch\NRediSearch.csproj", "{71455B07-E628-4F3A-9FFF-9EC63071F78E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NRediSearch.Test", "NRediSearch.Test\NRediSearch.Test.csproj", "{94D233F5-2400-4542-98B9-BA72005C57DC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
......@@ -109,6 +113,26 @@ Global
{7909952C-0F38-4E62-A7BA-1A77E1452FDA}.Release|Any CPU.Build.0 = Release|Any CPU
{7909952C-0F38-4E62-A7BA-1A77E1452FDA}.Verbose|Any CPU.ActiveCfg = Mono|Any CPU
{7909952C-0F38-4E62-A7BA-1A77E1452FDA}.Verbose|Any CPU.Build.0 = Mono|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Log Output|Any CPU.ActiveCfg = Release|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Log Output|Any CPU.Build.0 = Release|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Mono|Any CPU.ActiveCfg = Release|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Mono|Any CPU.Build.0 = Release|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Release|Any CPU.Build.0 = Release|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Verbose|Any CPU.ActiveCfg = Release|Any CPU
{71455B07-E628-4F3A-9FFF-9EC63071F78E}.Verbose|Any CPU.Build.0 = Release|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Log Output|Any CPU.ActiveCfg = Debug|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Log Output|Any CPU.Build.0 = Debug|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Mono|Any CPU.ActiveCfg = Debug|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Mono|Any CPU.Build.0 = Debug|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Release|Any CPU.Build.0 = Release|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Verbose|Any CPU.ActiveCfg = Debug|Any CPU
{94D233F5-2400-4542-98B9-BA72005C57DC}.Verbose|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
......
......@@ -528,6 +528,10 @@ static bool TryParseDouble(byte[] blob, out double value)
if (valueBlob == null) return null;
if (valueBlob.Length == 0) return "";
if (valueBlob.Length == 2 && valueBlob[0] == (byte)'O' && valueBlob[1] == (byte)'K')
{
return "OK"; // special case for +OK status results from modules
}
try
{
return Encoding.UTF8.GetString(valueBlob);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment